diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 71578ed1..5c835326 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -6,7 +6,7 @@ Complements the fuzz-based E2E tests (`test-client`): fuzz tests discover bugs t ## How it works -Each test is a `TestDefinition`: a name, a client count, and an ordered list of steps. The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. +Each test is a `TestDefinition`: a client count and an ordered list of steps. The test name is derived from the registry key (which matches the file name). The `TestRunner` spins up N `DeterministicAgent` instances (each wrapping a real `SyncClient` with an `InMemoryFileSystem`) pointed at a shared vault on the server, then executes steps one by one. Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process. @@ -14,7 +14,7 @@ All tests run in parallel up to a concurrency limit. ## Step types -Clients always start with syincing being disabled. +Clients always start with syncing disabled. **File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): - `create`, `update`, `rename`, `delete` @@ -26,11 +26,9 @@ Clients always start with syincing being disabled. **Server control:** - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process -- `wait` — sleep for N milliseconds **Assertions:** -- `assert-content`, `assert-exists`, `assert-not-exists` -- `assert-consistent` — all clients have identical files; optionally takes a custom verify function +- `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback ## Running @@ -56,18 +54,31 @@ npm run test -w deterministic-tests -- -j 4 import type { TestDefinition } from "../test-definition"; export const myScenarioTest: TestDefinition = { - name: "My Scenario", - description: "What this test verifies", + 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: "sync" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") } ] }; ``` +The `verify` callback receives an `AssertableState` object with chainable assertion methods: + +```typescript +s.assertFileCount(n) // exact file count +s.assertFileExists("path") // file must exist +s.assertFileNotExists("path") // file must not exist +s.assertContent("path", "expected") // exact content match +s.assertContains("path", "a", "b") // all substrings present +s.assertAnyFileContains("text") // substring in any file +s.assertContentInAtMostOneFile("text") // no duplicate content +s.ifFileExists("path", (s) => ...) // conditional assertion +``` + 2. Register it in `src/test-registry.ts`: ```typescript @@ -78,4 +89,3 @@ const TESTS = { "my-scenario": myScenarioTest }; ``` - diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 2815abae..57cee963 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -34,7 +34,7 @@ function testUsesPauseServer(test: TestDefinition): boolean { } interface NamedTestResult { - test: TestDefinition; + name: string; result: TestResult; } @@ -64,13 +64,13 @@ async function main(): Promise { const filterArg = process.argv.find((a) => a.startsWith("--filter=")); const filter = filterArg?.slice("--filter=".length); - const testsToRun: TestDefinition[] = []; + const testsToRun: [string, TestDefinition][] = []; for (const [key, test] of Object.entries(TESTS)) { if (test) { - if (filter && !key.includes(filter) && !test.name.toLowerCase().includes(filter.toLowerCase())) { + if (filter && !key.includes(filter)) { continue; } - testsToRun.push(test); + testsToRun.push([key, test]); } } @@ -84,8 +84,10 @@ async function main(): Promise { } const concurrency = parseConcurrency(); - const regularTests = testsToRun.filter((t) => !testUsesPauseServer(t)); - const pauseTests = testsToRun.filter((t) => testUsesPauseServer(t)); + const regularTests = testsToRun.filter( + ([, t]) => !testUsesPauseServer(t) + ); + const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); logger.info(`Server: ${serverPath}`); logger.info(`Config: ${configPath}`); @@ -113,7 +115,8 @@ async function main(): Promise { const results = await runWithConcurrency( regularTests, concurrency, - async (test) => runSharedServerTest(test, sharedServer) + async ([name, test]) => + runSharedServerTest(name, test, sharedServer) ); allResults.push(...results); @@ -137,7 +140,8 @@ async function main(): Promise { const results = await runWithConcurrency( pauseTests, concurrency, - async (test) => runDedicatedServerTest(test, serverPath, configPath) + async ([name, test]) => + runDedicatedServerTest(name, test, serverPath, configPath) ); allResults.push(...results); @@ -149,8 +153,8 @@ async function main(): Promise { logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`); if (failed.length > 0) { - for (const { test, result } of failed) { - logger.error(` FAILED: ${test.name}: ${result.error}`); + for (const { name, result } of failed) { + logger.error(` FAILED: ${name}: ${result.error}`); } process.exit(1); } else { @@ -165,27 +169,25 @@ main().catch((err: unknown) => { }); -/** - * Run a test on a shared server (for tests that don't use pause-server). - */ async function runSharedServerTest( + name: string, test: TestDefinition, sharedServer: ServerControl ): Promise { - const testLogger = new PrefixedLogger(logger, test.name); + const testLogger = new PrefixedLogger(logger, name); const runner = new TestRunner( sharedServer, testLogger, TOKEN, sharedServer.remoteUri ); - const result = await runner.runTest(test); + const result = await runner.runTest(name, test); if (result.success) { - logger.info(`PASSED: ${test.name} (${result.duration}ms)`); + logger.info(`PASSED: ${name} (${result.duration}ms)`); } else { - logger.error(`FAILED: ${test.name} - ${result.error}`); + logger.error(`FAILED: ${name} - ${result.error}`); } - return { test, result }; + return { name, result }; } /** @@ -194,11 +196,12 @@ async function runSharedServerTest( * isolated servers to avoid interfering with other tests. */ async function runDedicatedServerTest( + name: string, test: TestDefinition, serverPath: string, configPath: string ): Promise { - const testLogger = new PrefixedLogger(logger, test.name); + const testLogger = new PrefixedLogger(logger, name); const server = new ServerControl(serverPath, configPath, testLogger); serverManager.track(server); @@ -210,13 +213,13 @@ async function runDedicatedServerTest( TOKEN, server.remoteUri ); - const result = await runner.runTest(test); + const result = await runner.runTest(name, test); if (result.success) { - logger.info(`PASSED: ${test.name} (${result.duration}ms)`); + logger.info(`PASSED: ${name} (${result.duration}ms)`); } else { - logger.error(`FAILED: ${test.name} - ${result.error}`); + logger.error(`FAILED: ${name} - ${result.error}`); } - return { test, result }; + return { name, result }; } finally { try { await server.stop(); diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 3f4631b2..136d5ed8 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -1,4 +1,4 @@ -import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client"; +import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client"; import { SyncClient, debugging, LogLevel } from "sync-client"; import { assert } from "./utils/assert"; import { sleep } from "./utils/sleep"; @@ -16,6 +16,8 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { database: Partial; }> = {}; private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT; + private readonly syncErrors: Error[] = []; + private readonly pendingSyncOperations = new Set>(); public constructor( clientId: number, @@ -81,9 +83,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { const contentBytes = new TextEncoder().encode(content); this.files.set(path, contentBytes); - this.enqueueSync(async () => - this.client.syncLocallyCreatedFile(path) - ); + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyCreatedFile(path) + ); + } } public async updateFile(path: string, content: string): Promise { @@ -96,9 +100,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { const contentBytes = new TextEncoder().encode(content); this.files.set(path, contentBytes); - this.enqueueSync(async () => - this.client.syncLocallyUpdatedFile({ relativePath: path }) - ); + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyUpdatedFile({ relativePath: path }) + ); + } } public async renameFile(oldPath: string, newPath: string): Promise { @@ -109,11 +115,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { `File ${oldPath} does not exist on client ${this.clientId}` ); } - if (oldPath !== newPath && this.files.has(newPath)) { - this.log( - `Target path ${newPath} already exists, will be overwritten (ensureClearPath)` - ); - } this.files.set(newPath, file); if (oldPath !== newPath) { this.files.delete(oldPath); @@ -140,18 +141,47 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { public async waitForSync(): Promise { this.log("Waiting for sync to complete..."); + // Drain agent-level sync operations first. These are the fire-and-forget + // promises from enqueueSync() that call into the SyncClient's methods. + // Without this, waitUntilFinished() might return before the SyncClient + // has even been told about the operation. + await this.drainPendingSyncOperations(); await withTimeout( this.client.waitUntilFinished(), WAIT_TIMEOUT_MS, `Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms` ); + if (this.syncErrors.length > 0) { + const errors = this.syncErrors.splice(0); + throw new Error( + `Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}` + ); + } this.log("Sync complete"); } public async disableSync(): Promise { this.log("Disabling sync"); + // Drain pending enqueued operations before disabling so the SyncClient + // knows about all operations that were enqueued while sync was enabled. + await this.drainPendingSyncOperations(); await this.client.setSetting("isSyncEnabled", false); this.isSyncEnabled = false; + // Wait for in-flight operations to drain. Disabling sync triggers + // a reset, which aborts in-flight fetches with SyncResetError. + try { + await withTimeout( + this.client.waitUntilFinished(), + WAIT_TIMEOUT_MS, + `Client ${this.clientId} disableSync drain timed out` + ); + } catch (error) { + if (error instanceof Error && error.name === "SyncResetError") { + this.log("Disable sync drain interrupted by reset (expected)"); + } else { + throw error; + } + } } public async enableSync(): Promise { @@ -161,44 +191,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { await this.waitForWebSocket(); } - public async assertContent( - path: string, - expectedContent: string - ): Promise { - this.log(`Asserting content of ${path} equals "${expectedContent}"`); - const actualBytes = await this.read(path).catch(() => { - throw new Error( - `File ${path} does not exist on client ${this.clientId}` - ); - }); - const actualContent = new TextDecoder().decode(actualBytes); - assert( - actualContent === expectedContent, - `Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"` - ); - this.log(`✓ Content assertion passed for ${path}`); - } - - public async assertExists(path: string): Promise { - this.log(`Asserting ${path} exists`); - const exists = await this.exists(path); - assert( - exists, - `File ${path} does not exist on client ${this.clientId}` - ); - this.log(`✓ File ${path} exists`); - } - - public async assertNotExists(path: string): Promise { - this.log(`Asserting ${path} does not exist`); - const exists = await this.exists(path); - assert( - !exists, - `File ${path} exists on client ${this.clientId} but should not` - ); - this.log(`✓ File ${path} does not exist`); - } - public async getFiles(): Promise { return this.listFilesRecursively(); } @@ -217,6 +209,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { return; } try { + await this.drainPendingSyncOperations(); await withTimeout( this.client.waitUntilFinished(), WAIT_TIMEOUT_MS, @@ -233,6 +226,49 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { this.log("Cleanup complete"); } + // Yield the event loop before each FS operation so that the SyncClient's + // async calls create real interleaving points, matching the behavior of + // actual disk I/O. Without this, all FS operations resolve in the same + // microtask, hiding concurrency bugs that only manifest with real latency. + public override async read(path: RelativePath): Promise { + await Promise.resolve(); + return super.read(path); + } + + public override async write( + path: RelativePath, + content: Uint8Array + ): Promise { + await Promise.resolve(); + return super.write(path, content); + } + + public override async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + await Promise.resolve(); + return super.atomicUpdateText(path, updater); + } + + public override async exists(path: RelativePath): Promise { + await Promise.resolve(); + return super.exists(path); + } + + public override async delete(path: RelativePath): Promise { + await Promise.resolve(); + return super.delete(path); + } + + public override async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + await Promise.resolve(); + return super.rename(oldPath, newPath); + } + private async waitForWebSocket(): Promise { const deadline = Date.now() + WEBSOCKET_CONNECT_TIMEOUT_MS; while (!this.client.isWebSocketConnected && Date.now() < deadline) { @@ -244,11 +280,28 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { ); } + /** + * Wait until all agent-level enqueued sync operations have completed. + * Uses a loop because completing one operation can trigger new enqueues. + */ + private async drainPendingSyncOperations(): Promise { + while (this.pendingSyncOperations.size > 0) { + await Promise.all(this.pendingSyncOperations); + } + } + private enqueueSync(operation: () => Promise): void { - void this.executeSyncOperation(operation).catch((error) => { - this.log( - `Background sync failed (will retry on reconnect): ${error}` - ); + const promise = this.executeSyncOperation(operation).catch( + (error: unknown) => { + const err = + error instanceof Error ? error : new Error(String(error)); + this.log(`Background sync failed: ${err.message}`); + this.syncErrors.push(err); + } + ); + this.pendingSyncOperations.add(promise); + void promise.finally(() => { + this.pendingSyncOperations.delete(promise); }); } diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index 5c8aff17..c2d353db 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -104,7 +104,7 @@ export class ServerControl { public async waitForReady(maxAttempts = 50): Promise { const pingUrl = `${this.remoteUri}/vaults/test/ping`; for (let i = 0; i < maxAttempts; i++) { - if (this.process === null || this.process.exitCode !== null) { + if (this.process?.exitCode !== null) { throw new Error( "Server process died while waiting for it to become ready" ); diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index 51e162ee..8764e669 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -1,4 +1,4 @@ -import { ServerControl } from "./server-control"; +import type { ServerControl } from "./server-control"; import type { Logger } from "sync-client"; export class ServerManager { diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts index 453a5d01..f8dac1fe 100644 --- a/frontend/deterministic-tests/src/test-definition.ts +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -1,5 +1,8 @@ +import type { AssertableState } from "./utils/assertable-state"; + export interface ClientState { files: Map; + clientFiles: Map[]; } export type TestStep = @@ -13,13 +16,9 @@ export type TestStep = | { type: "pause-server" } | { type: "resume-server" } | { type: "barrier" } - | { type: "assert-content"; client: number; path: string; content: string } - | { type: "assert-exists"; client: number; path: string } - | { type: "assert-not-exists"; client: number; path: string } - | { type: "assert-consistent"; verify?: (state: ClientState) => void }; + | { type: "assert-consistent"; verify?: (state: AssertableState) => void }; export interface TestDefinition { - name: string; description?: string; clients: number; steps: TestStep[]; diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 6ff5c9d3..0785926b 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -1,86 +1,49 @@ import type { TestDefinition } from "./test-definition"; -import { writeWriteConflictTest } from "./tests/write-write-conflict.test"; import { renameCreateConflictTest } from "./tests/rename-create-conflict.test"; -import { createDeleteNoopTest } from "./tests/create-delete-noop.test"; import { renameChainTest } from "./tests/rename-chain.test"; -import { serverPauseResumeTest } from "./tests/server-pause-resume.test"; -import { createMergeDeleteTest } from "./tests/create-merge-delete.test"; import { renameUpdateConflictTest } from "./tests/rename-update-conflict.test"; import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test"; import { multiFileOperationsTest } from "./tests/multi-file-operations.test"; -import { duplicateContentFilesTest } from "./tests/duplicate-content-files.test"; import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test"; -import { rapidSyncToggleTest } from "./tests/rapid-sync-toggle.test"; -import { concurrentDeleteUpdateTest } from "./tests/concurrent-delete-update.test"; import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test"; -import { threeClientConvergenceTest } from "./tests/three-client-convergence.test"; -import { updateDuringServerPauseTest } from "./tests/update-during-server-pause.test"; -import { emptyFileSyncTest } from "./tests/empty-file-sync.test"; import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test"; -import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test"; -import { multipleUpdatesCoalesceTest } from "./tests/multiple-updates-coalesce.test"; -import { deleteNonexistentFileTest } from "./tests/delete-nonexistent-file.test"; -import { createWhileServerPausedTest } from "./tests/create-while-server-paused.test"; -import { interleavedOperationsTest } from "./tests/interleaved-operations.test"; import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test"; -import { largeFileCountTest } from "./tests/large-file-count.test"; -import { offlineOperationsBothClientsTest } from "./tests/offline-operations-both-clients.test"; -import { updateThenRenameTest } from "./tests/update-then-rename.test"; import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test"; -import { concurrentCreateSamePathMergeTest } from "./tests/concurrent-create-same-path-merge.test"; import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test"; -import { offlineMultiUpdateCatchupTest } from "./tests/offline-multi-update-catchup.test"; import { mcThreeClientRenameOfflineUpdateTest } from "./tests/mc-three-client-rename-offline-update.test"; import { mcMultiDeleteOfflineRenameTest } from "./tests/mc-multi-delete-offline-rename.test"; import { mcCrossCreateRenameSameTargetTest } from "./tests/mc-cross-create-rename-same-target.test"; import { mcDeleteThenOfflineRenameTest } from "./tests/mc-delete-then-offline-rename.test"; import { offlineMixedOperationsTest } from "./tests/offline-mixed-operations.test"; -import { offlineCreateRenameCreateTest } from "./tests/offline-create-rename-create.test"; import { offlineConcurrentRenamesTest } from "./tests/offline-concurrent-renames.test"; import { offlineMultipleEditsTest } from "./tests/offline-multiple-edits.test"; import { serverPauseBothClientsCreateTest } from "./tests/server-pause-both-clients-create.test"; -import { serverPauseRenameTest } from "./tests/server-pause-rename-propagation.test"; -import { serverPauseConcurrentCreatesTest } from "./tests/server-pause-concurrent-creates.test"; import { serverPauseUpdateAndCreateTest } from "./tests/server-pause-update-and-create.test"; import { renameSwapTest } from "./tests/rename-swap.test"; import { renameCircularTest } from "./tests/rename-circular.test"; -import { renameNestedPathTest } from "./tests/rename-nested-path.test"; import { renameRoundtripTest } from "./tests/rename-roundtrip.test"; import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-remote-create-old-path.test"; import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test"; import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test"; import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test"; import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test"; -import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test"; import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test"; import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test"; -import { offlineRenamePendingCreateTest } from "./tests/offline-rename-pending-create.test"; import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test"; import { moveAndConcurrentRemoteUpdateTest } from "./tests/move-and-concurrent-remote-update.test"; import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remote-update.test"; import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test"; -import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test"; -import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test"; import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test"; -import { renameTrackedToOccupiedPendingPathTest } from "./tests/rename-tracked-to-occupied-pending-path.test"; import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test"; -import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test"; -import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test"; import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test"; import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test"; import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test"; import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test"; import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test"; import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test"; -import { concurrentRenameAndCreateAtTargetTest } from "./tests/concurrent-rename-and-create-at-target.test"; -import { createRenameCreateSamePathOfflineTest } from "./tests/create-rename-create-same-path-offline.test"; import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test"; import { serverPauseBothEditSameFileTest } from "./tests/server-pause-both-edit-same-file.test"; -import { reconcilePendingAtOccupiedPathTest } from "./tests/reconcile-pending-at-occupied-path.test"; -import { offlineRenameBothClientsSameSourceTest } from "./tests/offline-rename-both-clients-same-source.test"; -import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test"; import { deleteRecreateDifferentContentTest } from "./tests/delete-recreate-different-content.test"; -import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test"; import { updateDuringCreateProcessingTest } from "./tests/update-during-create-processing.test"; import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remote-delete.test"; import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; @@ -90,109 +53,64 @@ import { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-d 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 { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test"; -import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test"; -import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test"; -import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test"; import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test"; import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; -import { remoteDeleteCoalesceLosesLocalUpdateTest } from "./tests/remote-delete-coalesce-loses-local-update.test"; -import { updateVsRemoteDeleteDataLossTest } from "./tests/update-vs-remote-delete-data-loss.test"; import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; -import { renameEmptyFileLosesIdentityTest } from "./tests/rename-empty-file-loses-identity.test"; import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; import { renameToPendingPathFallbackTest } from "./tests/rename-to-pending-path-fallback.test"; -import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test"; import { moveRemoteUpdateRevertsRenameTest } from "./tests/move-remote-update-reverts-rename.test"; -import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test"; import { localEditLostDuringCreateMergeTest } from "./tests/local-edit-lost-during-create-merge.test"; -import { concurrentBinaryCreateDeconflictionTest } from "./tests/concurrent-binary-create-deconfliction.test"; import { renamePendingCreateBeforeResponseTest } from "./tests/rename-pending-create-before-response.test"; import { createRenameResponseSkipsFileTest } from "./tests/create-rename-response-skips-file.test"; -import { staleDocOrphanDuplicateContentTest } from "./tests/stale-doc-orphan-duplicate-content.test"; +import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test"; +import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test"; +import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test"; export const TESTS: Partial> = { - "write-write-conflict": writeWriteConflictTest, "rename-create-conflict": renameCreateConflictTest, - "create-delete-noop": createDeleteNoopTest, "rename-chain": renameChainTest, - "server-pause-resume": serverPauseResumeTest, - "create-merge-delete": createMergeDeleteTest, "rename-update-conflict": renameUpdateConflictTest, "delete-rename-conflict": deleteRenameConflictTest, "multi-file-operations": multiFileOperationsTest, - "duplicate-content-files": duplicateContentFilesTest, "delete-recreate-same-path": deleteRecreateSamePathTest, - "rapid-sync-toggle": rapidSyncToggleTest, - "concurrent-delete-update": concurrentDeleteUpdateTest, "offline-rename-and-edit": offlineRenameAndEditTest, - "three-client-convergence": threeClientConvergenceTest, - "update-during-server-pause": updateDuringServerPauseTest, - "empty-file-sync": emptyFileSyncTest, "rename-to-existing-path": renameToExistingPathTest, - "concurrent-rename-same-target": concurrentRenameSameTargetTest, - "multiple-updates-coalesce": multipleUpdatesCoalesceTest, - "delete-nonexistent-file": deleteNonexistentFileTest, - "create-while-server-paused": createWhileServerPausedTest, - "interleaved-operations": interleavedOperationsTest, "simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest, - "large-file-count": largeFileCountTest, - "offline-operations-both-clients": offlineOperationsBothClientsTest, - "update-then-rename": updateThenRenameTest, "idempotency-after-server-pause": idempotencyAfterServerPauseTest, - "concurrent-create-same-path-merge": concurrentCreateSamePathMergeTest, "sequential-create-duplicate-content": sequentialCreateDuplicateContentTest, - "offline-multi-update-catchup": offlineMultiUpdateCatchupTest, "mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest, "mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest, "mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest, "mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest, "offline-mixed-operations": offlineMixedOperationsTest, - "offline-create-rename-create": offlineCreateRenameCreateTest, "offline-concurrent-renames": offlineConcurrentRenamesTest, "offline-multiple-edits": offlineMultipleEditsTest, "server-pause-both-clients-create": serverPauseBothClientsCreateTest, - "server-pause-rename-propagation": serverPauseRenameTest, - "server-pause-concurrent-creates": serverPauseConcurrentCreatesTest, "server-pause-update-and-create": serverPauseUpdateAndCreateTest, "rename-swap": renameSwapTest, "rename-circular": renameCircularTest, - "rename-nested-path": renameNestedPathTest, "rename-roundtrip": renameRoundtripTest, "offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest, "offline-edit-remote-rename": offlineEditRemoteRenameTest, "rename-chain-then-delete": renameChainThenDeleteTest, "offline-delete-remote-rename": offlineDeleteRemoteRenameTest, "rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest, - "create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest, "overlapping-edits-same-section": overlappingEditsSameSectionTest, "rapid-updates-after-merge": rapidUpdatesAfterMergeTest, - "offline-rename-pending-create": offlineRenamePendingCreateTest, "delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest, "move-and-concurrent-remote-update": moveAndConcurrentRemoteUpdateTest, "double-offline-cycle": doubleOfflineCycleTest, - "create-rename-create-same-path": createRenameCreateSamePathTest, - "concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest, "server-pause-rename-edit-resume": serverPauseRenameEditResumeTest, - "rename-tracked-to-occupied-pending-path": renameTrackedToOccupiedPendingPathTest, "offline-update-both-then-delete-one": offlineUpdateBothThenDeleteOneTest, - "move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest, - "coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest, "offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest, "delete-during-pending-create": deleteDuringPendingCreateTest, "three-client-rename-create-delete": threeClientRenameCreateDeleteTest, "key-migration-event-drop": keyMigrationEventDropTest, "rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest, "offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest, - "concurrent-rename-and-create-at-target": concurrentRenameAndCreateAtTargetTest, - "create-rename-create-same-path-offline": createRenameCreateSamePathOfflineTest, "rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest, "server-pause-both-edit-same-file": serverPauseBothEditSameFileTest, - "reconcile-pending-at-occupied-path": reconcilePendingAtOccupiedPathTest, - "offline-rename-both-clients-same-source": offlineRenameBothClientsSameSourceTest, - "create-during-reconciliation": createDuringReconciliationTest, "delete-recreate-different-content": deleteRecreateDifferentContentTest, - "move-chain-three-files": moveChainThreeFilesTest, "update-during-create-processing": updateDuringCreateProcessingTest, "offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest, "reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest, @@ -203,24 +121,16 @@ export const TESTS: Partial> = { "move-preserves-remote-update": movePreservesRemoteUpdateTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, - "user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest, - "concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest, - "concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest, - "binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest, "failed-vfs-move-falls-back": failedVfsMoveFallsBackTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, - "remote-delete-coalesce-loses-local-update": remoteDeleteCoalesceLosesLocalUpdateTest, - "update-vs-remote-delete-data-loss": updateVsRemoteDeleteDataLossTest, "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, - "rename-empty-file-loses-identity": renameEmptyFileLosesIdentityTest, "queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest, "rename-to-pending-path-fallback": renameToPendingPathFallbackTest, - "coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest, "move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest, - "create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest, "local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest, - "concurrent-binary-create-deconfliction": concurrentBinaryCreateDeconflictionTest, "rename-pending-create-before-response": renamePendingCreateBeforeResponseTest, "create-rename-response-skips-file": createRenameResponseSkipsFileTest, - "stale-doc-orphan-duplicate-content": staleDocOrphanDuplicateContentTest + "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest, + "concurrent-rename-first-wins": concurrentRenameFirstWinsTest, + "binary-to-text-transition": binaryToTextTransitionTest, }; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index d9a42fa0..05ac1611 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -1,13 +1,13 @@ import type { TestDefinition, TestResult, - TestStep, - ClientState + TestStep } from "./test-definition"; import { DeterministicAgent } from "./deterministic-agent"; import type { ServerControl } from "./server-control"; import type { SyncSettings, Logger } from "sync-client"; import { assert } from "./utils/assert"; +import { AssertableState } from "./utils/assertable-state"; import { sleep } from "./utils/sleep"; import { withTimeout } from "./utils/with-timeout"; import { @@ -37,9 +37,12 @@ export class TestRunner { this.remoteUri = remoteUri; } - public async runTest(test: TestDefinition): Promise { + public async runTest( + name: string, + test: TestDefinition + ): Promise { const startTime = Date.now(); - this.logger.info(`Running test: ${test.name}`); + this.logger.info(`Running test: ${name}`); if (test.description !== undefined && test.description !== "") { this.logger.info(`Description: ${test.description}`); } @@ -65,7 +68,7 @@ export class TestRunner { await this.cleanup(); const duration = Date.now() - startTime; - this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`); + this.logger.info(`\n✓ Test passed: ${name} (${duration}ms)`); return { success: true, @@ -75,7 +78,7 @@ export class TestRunner { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.info(`\n✗ Test failed: ${test.name}`); + this.logger.info(`\n✗ Test failed: ${name}`); this.logger.info(`Error: ${errorMessage}`); await this.cleanup(); @@ -192,21 +195,6 @@ export class TestRunner { await this.waitForConvergence(); break; - case "assert-content": - await this.getAgent(step.client).assertContent( - step.path, - step.content - ); - break; - - case "assert-exists": - await this.getAgent(step.client).assertExists(step.path); - break; - - case "assert-not-exists": - await this.getAgent(step.client).assertNotExists(step.path); - break; - case "assert-consistent": await this.assertConsistent(step.verify); break; @@ -263,17 +251,21 @@ export class TestRunner { } /** - * Wait for all agents to be simultaneously idle. Two full rounds are - * needed because completing work on agent A can trigger a server - * broadcast that enqueues new work on agent B, and vice versa. - * - * However, the 2nd sync may result in merges which can trigger another - * round of syncs, so this function should be called in a loop with a - * timeout to ensure true convergence rather than just waiting for the - * current round of syncs to complete. + * Wait for all agents to be simultaneously idle. + * + * Completing work on agent A can trigger a server broadcast that + * enqueues new work on agent B, which can cascade further. With N + * agents the worst-case cascade depth is N (a chain A→B→C→…→A), + * so we run N+1 sequential passes to drain it. Extra passes are + * essentially free when there is no outstanding work. + * + * The outer {@link waitForConvergence} loop with consistency checks + * remains the ultimate guarantee — this method just minimizes how + * many slow retry iterations are needed. */ private async waitAllAgentsSettled(): Promise { - for (let round = 0; round < 2; round++) { + const rounds = this.agents.length + 1; + for (let round = 0; round < rounds; round++) { for (const agent of this.agents) { await agent.waitForSync(); } @@ -281,47 +273,52 @@ export class TestRunner { } private async assertConsistent( - verify?: (state: ClientState) => void + 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"); - const [referenceAgent] = this.agents; - const referenceFiles = (await referenceAgent.getFiles()).sort(); - const referenceState: ClientState = { files: new Map() }; - - for (const file of referenceFiles) { - const content = await referenceAgent.getFileContent(file); - referenceState.files.set(file, content); + // Snapshot all agents' file states upfront to minimize the window + // where background sync could mutate state between reads. + const clientFiles: Map[] = []; + for (const agent of this.agents) { + const sortedFiles = (await agent.getFiles()).sort(); + const fileMap = new Map(); + for (const file of sortedFiles) { + const content = await agent.getFileContent(file); + fileMap.set(file, content); + } + clientFiles.push(fileMap); } + const referenceFiles = Array.from(clientFiles[0].keys()); + this.logger.info( `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` ); - for (let i = 1; i < this.agents.length; i++) { - const agent = this.agents[i]; - const files = (await agent.getFiles()).sort(); + for (let i = 1; i < clientFiles.length; i++) { + const agentFileKeys = Array.from(clientFiles[i].keys()); this.logger.info( - `Client ${i} has ${files.length} files: ${files.join(", ")}` + `Client ${i} has ${agentFileKeys.length} files: ${agentFileKeys.join(", ")}` ); assert( - files.length === referenceFiles.length, - `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files` + agentFileKeys.length === referenceFiles.length, + `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${agentFileKeys.length} files` ); - for (let j = 0; j < files.length; j++) { + for (let j = 0; j < agentFileKeys.length; j++) { assert( - files[j] === referenceFiles[j], - `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"` + agentFileKeys[j] === referenceFiles[j], + `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${agentFileKeys[j]}"` ); } for (const file of referenceFiles) { - const referenceContent = referenceState.files.get(file); - const agentContent = await agent.getFileContent(file); + const referenceContent = clientFiles[0].get(file); + const agentContent = clientFiles[i].get(file); assert( referenceContent === agentContent, @@ -335,7 +332,12 @@ export class TestRunner { if (verify) { this.logger.info("Running custom verification..."); try { - verify(referenceState); + verify( + new AssertableState({ + files: clientFiles[0], + clientFiles + }) + ); } catch (error) { const msg = error instanceof Error ? error.message : String(error); 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 506e2b59..77f053ff 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,11 +1,9 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const textPendingCreateNotDisplacedTest: TestDefinition = { - name: "Both offline binary creates at same path survive sync", description: - "Two clients each create a binary file at the same path while offline. " + - "After syncing, both files should exist on both clients at separate paths.", + "Two clients each create a text file at the same path while offline. " + + "After syncing, the file should contain merged content from both clients.", clients: 2, steps: [ { @@ -25,16 +23,6 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothFilesExist } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") } ] }; - -function verifyBothFilesExist(state: AssertableState): void { - state - .assertFileCount(1) - .assertFileExists("data.txt") - .assertAnyFileContains( - "data from client 0", - "data from 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 baa8bc52..94e6914e 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentUpdateDiffConsistencyTest: TestDefinition = { - name: "Concurrent edits to different sections merge correctly", description: "Both clients edit different sections of the same file while offline. " + "After syncing, the merged file should contain both edits.", 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 new file mode 100644 index 00000000..8be438e2 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts @@ -0,0 +1,46 @@ +import type { TestDefinition } from "../test-definition"; + +export const userParenthesizedFileNotDeletedTest: TestDefinition = { + description: + "A user-created file named 'Chapter (1).bin' alongside 'Chapter.bin' should not " + + "be mistakenly removed when another client creates a conflicting file.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + + { + type: "create", + client: 0, + path: "Chapter.bin", + content: "chapter one" + }, + { + type: "create", + client: 0, + path: "Chapter (1).bin", + content: "chapter one notes" + }, + + { type: "sync", client: 0 }, + + { + type: "create", + client: 1, + path: "Chapter.bin", + content: "chapter one notes" + }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertFileExists("Chapter.bin") + .assertFileExists("Chapter (1).bin") + .assertFileExists("Chapter (2).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 f575fc79..b1239217 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createDeleteNoopTest: TestDefinition = { - name: "Offline create then delete results in no file", description: "A client creates a file, updates it multiple times, then deletes it, all while " + "offline. After syncing, neither client should have the file.", @@ -17,8 +16,6 @@ export const createDeleteNoopTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "temp.md" }, - { type: "assert-not-exists", client: 1, path: "temp.md" }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => 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 4a40b59f..4b121939 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createMergeDeleteTest: TestDefinition = { - name: "Concurrent Create, Merge, Then Delete", description: "Two clients create A.md offline with different content. Both come online and " + "the content is merged. Then one client deletes A.md. Both clients should " + @@ -23,8 +22,6 @@ export const createMergeDeleteTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: (state) => state.assertFileCount(0) } + { type: "assert-consistent", verify: (s) => 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 91a52496..9c0f7245 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const moveIdenticalContentAmbiguityTest: TestDefinition = { - name: "Move Detection Ambiguity With Identical Content", description: "Two files with identical content exist. One is deleted and the other renamed " + "while offline. The system should still converge correctly despite the ambiguity.", @@ -23,19 +22,6 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "identical content" - }, - { type: "disable-sync", client: 1 }, { type: "delete", client: 1, path: "A.md" }, { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, diff --git a/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts deleted file mode 100644 index f51370a6..00000000 --- a/frontend/deterministic-tests/src/tests/14-write-write-conflict.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const writeWriteConflictTest: TestDefinition = { - name: "Write/Write Conflict", - description: - "Two clients simultaneously create the same file with different content. " + - "Both contributions should be preserved in the merged result without duplication.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "hello" }, - { type: "create", client: 1, path: "A.md", content: "hello" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - { - type: "assert-consistent", - verify: (state) => { - state - .assertFileCount(1) - .assertContent("A.md", "hello") - } - } - ] -}; 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 26931478..608f845d 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createUpdateCoalesceServerPauseTest: TestDefinition = { - name: "Create and Immediate Update While Server Is Paused", description: "Client creates a file and immediately updates it while the server is " + "paused. When the server resumes, both clients should have the final " + 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 988832c5..54dc3f98 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const createDuringReconciliationTest: TestDefinition = { - name: "File Created Right After Reconnect Syncs Correctly", description: "Client creates two files while offline, reconnects, then immediately " + "creates a third file. All three files should sync to the other client.", 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 new file mode 100644 index 00000000..f600c40e --- /dev/null +++ b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts @@ -0,0 +1,44 @@ +import type { TestDefinition } from "../test-definition"; + +export const createMergePreservesRenamedUpdateTest: TestDefinition = { + description: + "Both clients create the same file, which gets merged. One client goes " + + "offline, renames the file, updates it, and creates a new file at the " + + "original path. After reconnecting, the updated content must be preserved.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "doc.md", content: "alpha" }, + { type: "create", client: 1, path: "doc.md", content: "beta" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 1 }, + + { + type: "rename", + client: 1, + oldPath: "doc.md", + newPath: "moved.md" + }, + { + type: "update", + client: 1, + path: "moved.md", + content: "alpha beta extra-update" + }, + + { + type: "create", + client: 1, + path: "doc.md", + content: "new-content" + }, + + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (state) => 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 new file mode 100644 index 00000000..2b169a1d --- /dev/null +++ b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts @@ -0,0 +1,34 @@ +import type { TestDefinition } from "../test-definition"; + +export const createRenameCreateSamePathTest: TestDefinition = { + description: + "Client creates A.md, renames to B.md, creates new A.md, renames " + + "to C.md, creates yet another A.md. All three files should exist " + + "as separate documents on both clients.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "first file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + + { type: "create", client: 0, path: "A.md", content: "second file" }, + { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "third file" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "sync" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertContent("B.md", "first file") + .assertContent("C.md", "second file") + .assertContent("A.md", "third 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 new file mode 100644 index 00000000..a6c6851b --- /dev/null +++ b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts @@ -0,0 +1,41 @@ +import type { TestDefinition } from "../test-definition"; + +export const moveChainThreeFilesTest: TestDefinition = { + description: + "Three files have their contents rotated (A gets C's content, B gets A's, C gets B's) " + + "while offline. After reconnecting, both clients should converge with the rotated contents.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + { type: "create", client: 0, path: "A.md", content: "was A" }, + { type: "create", client: 0, path: "B.md", content: "was B" }, + { type: "create", client: 0, path: "C.md", content: "was C" }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "delete", client: 0, path: "B.md" }, + { type: "delete", client: 0, path: "C.md" }, + + { type: "create", client: 0, path: "A.md", content: "was C" }, + { type: "create", client: 0, path: "B.md", content: "was A" }, + { type: "create", client: 0, path: "C.md", content: "was B" }, + + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state + .assertFileCount(3) + .assertContent("A.md", "was C") + .assertContent("B.md", "was A") + .assertContent("C.md", "was B"); + } + } + ] +}; 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 5ad89cbe..0616136b 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,8 +1,6 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const binaryPendingCreateNotDisplacedTest: TestDefinition = { - name: "Both offline binary creates at same path survive sync", description: "Two clients each create a binary file at the same path while offline. " + "After syncing, both files should exist on both clients at separate paths.", @@ -25,17 +23,6 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothFilesExist } + { 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") } ] }; - -function verifyBothFilesExist(state: AssertableState): void { - state - .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 d66a2cf3..33fb8107 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { - name: "Local and remote edits to the same file are both preserved", description: "Client 0 edits a file while client 1 is offline. Client 1 reconnects " + "and immediately edits the same file. Both edits should be preserved.", 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 2d8fd4b6..15fe3e82 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,29 +1,24 @@ import type { TestDefinition } from "../test-definition"; -import type { AssertableState } from "../utils/assertable-state"; export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { - name: "Coalesced Remote Updates Lose Earlier vaultUpdateIds", description: - "When multiple remote-update events for the same document coalesce, " + - "only the last vaultUpdateId is recorded. Earlier IDs create " + - "permanent watermark gaps that cause unnecessary server replays " + - "on every reconnect.", + "Client 0 sends three rapid updates. After syncing, both clients " + + "disconnect and reconnect twice. Content should remain correct " + + "after each reconnect.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - // Client 0 sends three rapid updates { 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: verifyContent }, + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, @@ -31,18 +26,13 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent }, + { type: "assert-consistent", verify: (s) => 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: verifyContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") } ] }; - - -function verifyContent(state: AssertableState): void { - state.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 1a4014ac..3108ecfe 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,8 +1,6 @@ -import { AssertableState } from "src/utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { - name: "Delete and remote update of same file do not crash", description: "One client updates a file while the other deletes it at the same " + "time. Both clients should converge without errors.", 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 93cc6fc3..08778488 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentEditExactSamePositionTest: TestDefinition = { - name: "Concurrent edits to the exact same word are both preserved", description: "Both clients replace the same word in a file with different text " + "while offline. After syncing, the merged result should contain " + @@ -17,12 +16,6 @@ export const concurrentEditExactSamePositionTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "the quick brown fox" - }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, 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 7c08b392..3e71ed7d 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { - name: "Rename to path where another client creates a file", 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 " + 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 4cd7c1d9..9f0b0318 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { - name: "Rename to path where another client creates a file", 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 " + 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 e0419a47..230c7a1d 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,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameSameTargetTest: TestDefinition = { - name: "Two clients rename different files to the same target path", description: "One client renames A to C while the other renames B to C, both offline. " + "After syncing, both file contents should be preserved via path deconfliction.", 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 new file mode 100644 index 00000000..d6e9d43f --- /dev/null +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -0,0 +1,47 @@ +import type { TestDefinition } from "../test-definition"; + +export const binaryToTextTransitionTest: TestDefinition = { + description: + "A .bin file is created and synced. Both clients edit it offline, " + + "then it is renamed to .md. Both clients edit different sections " + + "offline again. The second merge should preserve both edits.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "data.bin", content: "original content" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertContent("data.bin", "original content") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "data.bin", content: "version A from client 0" }, + { type: "update", client: 1, path: "data.bin", content: "version B from client 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") }, + + { type: "disable-sync", client: 1 }, + { type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" }, + { type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") }, + ], +}; 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 new file mode 100644 index 00000000..1dddcf7a --- /dev/null +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -0,0 +1,36 @@ +import type { TestDefinition } from "../test-definition"; + +export const concurrentRenameFirstWinsTest: TestDefinition = { + description: + "Both clients start online with the same file. Both go offline, " + + "rename the file to different paths, and edit it. When they reconnect, " + + "the first rename to reach the server wins the path and both content " + + "edits are merged.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "line 1\nline 2\nline 3" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "line 1\nline 2\nline 3") }, + + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, + { type: "update", client: 0, path: "B.md", content: "edit from 0\nline 2\nline 3" }, + + { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, + { type: "update", client: 1, path: "C.md", content: "line 1\nline 2\nedit from 1" }, + + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => { + s.assertFileNotExists("A.md"); + s.assertFileCount(1); + s.assertAnyFileContains("edit from 0", "edit from 1"); + } }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts deleted file mode 100644 index ef29a279..00000000 --- a/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("from-zero") && content.includes("from-one"), - `Expected A.md to contain both "from-zero" and "from-one", got: "${content}"` - ); -} - -function verifyEmpty(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files after deletion, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} - -export const createMergeDeleteTest: TestDefinition = { - name: "Concurrent Create, Merge, Then Delete", - description: - "Two clients simultaneously create A.md with different content. " + - "The server merges them and both converge. Then Client 0 deletes A.md. " + - "Both clients should converge on an empty state.", - clients: 2, - steps: [ - // Both clients create A.md offline with different content - { type: "create", client: 0, path: "A.md", content: "from-zero" }, - { type: "create", client: 1, path: "A.md", content: "from-one" }, - - // Enable sync — both creates race to the server - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Phase 1: verify merge happened correctly - { type: "assert-consistent", verify: verifyMergedContent }, - - // Phase 2: Client 0 deletes the merged file - { type: "delete", client: 0, path: "A.md" }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should have no files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyEmpty } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts deleted file mode 100644 index ef70c6bd..00000000 --- a/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: When a create-merge returns an existing documentId, the stale - * tracked record at a different path must NOT have its file deleted if the - * file contains unsynchronized local modifications. - * - * Scenario (simplified from E2E log_4 failure): - * 1. Both clients create "doc.md" → server merges → both have docX - * 2. Client 1 goes offline, renames "doc.md" → "moved.md", updates it - * 3. Client 1 also creates a new file at the OLD path "doc.md" - * 4. Client 1 comes back online - * 5. The update at "doc.md" sends new content to the server (overwriting docX) - * 6. The create for "moved.md" may merge on the server - * 7. The content appended in step 2 must still be present somewhere - * - * Previously, ensureUniqueDocumentId would delete the renamed file even - * if it had unsynchronized local modifications, silently losing data. - */ -function verifyAllContentPreserved(state: ClientState): void { - const allContent = [...state.files.values()].join("\n"); - assert( - allContent.includes("extra-update"), - `Expected "extra-update" to be preserved somewhere in the files, but got:\n${[...state.files.entries()].map(([k, v]) => ` ${k}: "${v}"`).join("\n")}` - ); -} - -export const createMergePreservesRenamedUpdateTest: TestDefinition = { - name: "Create-Merge Preserves Renamed File With Local Updates", - description: - "When a create request merges with an existing document, " + - "a renamed copy of that document with unsynchronized updates " + - "must not be deleted.", - clients: 2, - steps: [ - // Setup: both clients create at the same path → server merges - { type: "create", client: 0, path: "doc.md", content: "alpha" }, - { type: "create", client: 1, path: "doc.md", content: "beta" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 1 goes offline and makes local changes - { type: "disable-sync", client: 1 }, - - // Rename the merged doc to a new path and update it - { - type: "rename", - client: 1, - oldPath: "doc.md", - newPath: "moved.md" - }, - { - type: "update", - client: 1, - path: "moved.md", - content: "alpha beta extra-update" - }, - - // Create a new file at the original path - { - type: "create", - client: 1, - path: "doc.md", - content: "new-content" - }, - - // Come back online — the reconciliation will detect: - // - "doc.md" in VFS (tracked) but with different content → update - // - "moved.md" not in VFS → create - // The create for "moved.md" may merge with the server's doc, - // triggering ensureUniqueDocumentId - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Verify: "extra-update" must still exist in some file - { type: "assert-consistent", verify: verifyAllContentPreserved } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts deleted file mode 100644 index 7f82c7ab..00000000 --- a/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyThreeFiles(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - assert( - state.files.size === 3, - `Expected 3 files, got ${state.files.size}: ${files.join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md (first file renamed), got: ${files.join(", ")}` - ); - assert( - state.files.has("C.md"), - `Expected C.md (second file renamed), got: ${files.join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md (third file still at original path), got: ${files.join(", ")}` - ); - - const bContent = state.files.get("B.md") ?? ""; - const cContent = state.files.get("C.md") ?? ""; - const aContent = state.files.get("A.md") ?? ""; - assert( - bContent === "first file", - `Expected B.md to contain "first file", got: "${bContent}"` - ); - assert( - cContent === "second file", - `Expected C.md to contain "second file", got: "${cContent}"` - ); - assert( - aContent === "third file", - `Expected A.md to contain "third file", got: "${aContent}"` - ); -} - -/** - * BUG: Tests the queue key migration for pending creates. When a file - * is created at path A, then renamed to B (freeing path A), then a new - * file is created at A, the event coalescing must migrate the first - * create's key from "path:A" to "path:B" so the second create doesn't - * coalesce with the first. - * - * Without key migration (lines 54-68 in sync-event-queue.ts), the - * second create at "path:A" would find the first create's state and - * coalesce with it, losing the second file. - */ -export const createRenameCreateSamePathTest: TestDefinition = { - name: "Create-Rename-Create at Same Path (Three Files)", - description: - "Client creates A.md, renames to B.md, creates new A.md, renames " + - "to C.md, creates yet another A.md. All three files should exist " + - "as separate documents. Tests queue key migration when pending " + - "creates are renamed before sync.", - clients: 2, - steps: [ - // Create first file at A.md, rename to B.md - { type: "create", client: 0, path: "A.md", content: "first file" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - - // Create second file at A.md (now free), rename to C.md - { type: "create", client: 0, path: "A.md", content: "second file" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, - - // Create third file at A.md - { type: "create", client: 0, path: "A.md", content: "third file" }, - - // Enable sync - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // All three files should exist on both clients - { type: "assert-consistent", verify: verifyThreeFiles } - ] -}; 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 4d0bf2a6..5bec2bcb 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,46 +1,16 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Regression guard for the create+rename race from e2e log_4.log. - * - * In the e2e test, timing jitter caused the HTTP response to arrive - * between the create and rename being coalesced by the sync queue, - * orphaning the document. This is documented in CLAUDE.md as a known - * limitation of concurrent creates at the same path. - * - * The deterministic test framework serializes steps, so the event - * coalescing correctly handles the create+rename sequence here. - * This test serves as a regression guard — if the coalescing logic - * changes, this test will catch regressions. - */ -function verifyBothClientsHaveContent(state: ClientState): void { - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - const [content] = Array.from(state.files.values()); - assert( - content === "the-content", - `Expected file to have "the-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const createRenameResponseSkipsFileTest: TestDefinition = { - name: "Create Then Immediate Rename — File Not Lost", description: - "Client creates a file online then immediately renames it. " + - "The create response arrives at the original path. " + - "The other client must receive the file content.", + "Client 0 creates a file online then immediately renames it. " + + "Client 1 must receive the file content at the renamed path.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates doc.md while online (HTTP request fires immediately) { type: "create", client: 0, @@ -48,7 +18,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { content: "the-content" }, - // Immediately rename — the create request is already in-flight { type: "rename", client: 0, @@ -56,12 +25,10 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { newPath: "renamed.md" }, - // Let everything sync { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // Both clients must have the content (at whatever path) - { type: "assert-consistent", verify: verifyBothClientsHaveContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertAnyFileContains("the-content") } ] }; diff --git a/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts b/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts deleted file mode 100644 index 25badba4..00000000 --- a/frontend/deterministic-tests/src/tests/create-while-server-paused.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const createWhileServerPausedTest: TestDefinition = { - name: "Create While Server Paused Then Resume", - description: - "Server is paused. Client 0 creates a file (request will stall). " + - "Then server resumes. File should sync to Client 1.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Pause server first, then create - { type: "pause-server" }, - { type: "create", client: 0, path: "paused-create.md", content: "created during pause" }, - { type: "resume-server" }, - - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-exists", client: 0, path: "paused-create.md" }, - { type: "assert-exists", client: 1, path: "paused-create.md" }, - { - type: "assert-content", - client: 1, - path: "paused-create.md", - content: "created during pause" - }, - { type: "assert-consistent" } - ] -}; 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 new file mode 100644 index 00000000..204e9896 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -0,0 +1,24 @@ +import type { TestDefinition } from "../test-definition"; + +export const deleteByOtherClientThenRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and the delete propagates. Then client 0 " + + "creates a new file at the same path. Both clients must have the file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { type: "assert-consistent", verify: (s) => s.assertFileNotExists("A.md") }, + + { 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") }, + ], +}; 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 712215c7..f6236060 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,34 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: File deleted locally while a create request is in-flight. - * - * The create request succeeds on the server, but by the time - * applyServerResponse runs, the document has been removed from pathIndex - * (deleted locally). The code at sync-actions.ts line 256-283 handles this: - * it confirms the create (so the server has a documentId), then immediately - * marks it as deleted-locally so the delete can be sent to the server. - * - * This test verifies that: - * 1. The file is properly deleted on both clients - * 2. No orphaned documents exist on the server - * 3. No duplicate documentIds in the VFS - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteDuringPendingCreateTest: TestDefinition = { - name: "Delete During Pending Create (Server Paused)", description: - "Client creates a file, server is paused so the create request stalls. " + - "Client then deletes the file while the create is in-flight. When the " + - "server resumes, the create succeeds but the file should still end up " + - "deleted on both clients.", + "Client 0 creates a file while the server is paused, then deletes it before the server resumes. " + + "After resume, the file should end up deleted on both clients.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -36,10 +11,8 @@ export const deleteDuringPendingCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so the create request stalls { type: "pause-server" }, - // Client 0 creates a file (HTTP request will stall) { type: "create", client: 0, @@ -47,19 +20,12 @@ export const deleteDuringPendingCreateTest: TestDefinition = { content: "this will be deleted" }, - // Wait a bit to ensure the create is queued - - // Client 0 deletes the file while create is pending { type: "delete", client: 0, path: "ephemeral.md" }, - // Resume server — the create request completes, then delete follows { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // File should be gone on both clients - { type: "assert-not-exists", client: 0, path: "ephemeral.md" }, - { type: "assert-not-exists", client: 1, path: "ephemeral.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { type: "assert-consistent", verify: (s) => 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 080f0810..c95c6aa4 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,48 +1,21 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should exist — the recreate creates a new document - assert( - state.files.has("A.md"), - `Expected A.md to exist. Files: ${files.join(", ")}` - ); - - const content = state.files.get("A.md") ?? ""; - - // The recreated content must be present. Client 1's update targeted - // the old (deleted) document, so it may also appear if the server - // merged both — but at minimum the recreated content must survive. - assert( - content.includes("recreated"), - `Expected A.md to contain "recreated" from client 0's recreate, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteRecreateConcurrentUpdateTest: TestDefinition = { - name: "Delete + Recreate with Concurrent Remote Update", description: - "Client 0 deletes A.md and recreates it with new content while offline. " + - "Client 1 (online) updates A.md with different content. When Client 0 " + - "reconnects, the system must reconcile the delete-recreate with the " + - "concurrent update. Both clients must converge.", + "Client 0 deletes and recreates A.md with new content while offline. Client 1 updates A.md concurrently. " + + "After client 0 reconnects, both clients must converge with client 0's recreated content preserved.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline, deletes and recreates { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, { type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, - // Client 1 updates the same file concurrently { type: "update", client: 1, @@ -51,12 +24,10 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients must converge - { type: "assert-consistent", verify: verifyConvergence } + { type: "assert-consistent", verify: (s) => 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 87e8075a..fd483419 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,55 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Delete and immediately recreate at the same path with - * different content, while the other client is editing. - * - * This exercises the coalescing path: delete + create = create. - * But the tricky part is that the ORIGINAL document at this path - * was tracked (had a documentId). The delete marks it as deleted-locally. - * The subsequent create makes a NEW pending document at the same path. - * - * Meanwhile, Client 1 has been editing the same file. When both sync: - * - Client 0's delete should go through first - * - Client 0's create creates a NEW document on the server - * - Client 1's edit to the OLD document may conflict - * - * The coalescing turns delete+create into just "create". But the executor - * for "create" at sync-actions.ts line 247 checks the VFS: if a tracked - * doc exists at the path, it treats the create as an update instead. - * Since the delete was coalesced away, the tracked doc STILL exists - * in the VFS at the time of execution → the "create" is treated as an - * update to the existing document, not a new document. - * - * This might be correct (updates the existing doc with new content) or - * might be a bug (should create a new documentId). The test verifies - * convergence either way. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - // Both client contents should be merged (empty-parent 3-way merge) - assert( - content.includes("brand new content") && - content.includes("edit from client 1"), - `Expected merged content with both edits, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const deleteRecreateDifferentContentTest: TestDefinition = { - name: "Delete + Recreate Same Path While Other Client Edits", description: - "Client 0 deletes and recreates A.md with new content while " + - "Client 1 edits A.md. The coalesced delete+create should produce " + - "correct behavior and both clients should converge.", + "Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " + + "Both clients should converge with content from both sides merged.", clients: 2, steps: [ - // Setup: create A.md { type: "create", client: 0, @@ -61,11 +17,9 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0: delete and recreate with new content { type: "delete", client: 0, path: "A.md" }, { type: "create", @@ -74,7 +28,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { content: "brand new content" }, - // Client 1: edit the same file { type: "update", client: 1, @@ -82,13 +35,12 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { content: "edit from client 1" }, - // Reconnect both { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from 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 e9e6116c..10b00f70 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,21 +1,18 @@ import type { TestDefinition } from "../test-definition"; export const deleteRecreateSamePathTest: TestDefinition = { - name: "Delete Then Recreate at Same Path", description: "Client 0 creates A.md, syncs. Then deletes A.md and creates a new A.md " + "with different content. Both clients should converge on the new content.", clients: 2, steps: [ - // Setup: create and sync A.md { 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-content", client: 1, path: "A.md", content: "version 1" }, + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 1") }, - // Client 0 deletes then recreates A.md with new content { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, { type: "create", client: 0, path: "A.md", content: "version 2" }, @@ -23,21 +20,6 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both clients should have the new content - { type: "assert-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { - type: "assert-content", - client: 0, - path: "A.md", - content: "version 2" - }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "version 2" - }, - { type: "assert-consistent" } + { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") } ] }; 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 aae562bf..4cbeed25 100644 --- a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -1,71 +1,34 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md must exist (unaffected by the conflict) - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-b", - `Expected B.md to have "content-b", got: "${state.files.get("B.md")}"` - ); - - // A.md should not exist (either deleted or renamed away) - assert( - !state.files.has("A.md"), - `A.md should not exist after conflict resolution, got: ${files.join(", ")}` - ); - - // If C.md exists (rename won over delete), it should have content-a - if (state.files.has("C.md")) { - assert( - state.files.get("C.md") === "content-a", - `If C.md exists, it should have "content-a", got: "${state.files.get("C.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const deleteRenameConflictTest: TestDefinition = { - name: "Delete vs Rename Conflict", description: - "Client 0 deletes A.md while Client 1 (offline) renames A.md to C.md. " + - "When Client 1 reconnects, the system must reconcile the conflicting " + - "operations. Both clients should converge to the same state.", + "Client 0 deletes A.md while client 1 renames A.md to C.md offline. " + + "After client 1 reconnects, both clients should converge to the same state.", clients: 2, steps: [ - // Setup: create A.md and B.md, sync to both clients { 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: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, + { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) renames A.md to C.md { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Both clients must converge — the key invariant is consistency. - // B.md should still exist on both (unaffected by the conflict). - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyConflictResolution } + { 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")); + } }, ] }; 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 1b146a0e..1034ce27 100644 --- a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -1,42 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyAllEdits(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "third edit", - `Expected doc.md to contain "third edit", got: "${content}"` - ); -} - -/** - * Tests two consecutive offline→online cycles. Client 0 goes offline, - * edits, comes online (first cycle). Then goes offline again, edits - * more, comes online (second cycle). All edits should propagate to - * Client 1. - * - * This exercises the runningReconciliation lifecycle: it must be - * cleared after the first cycle so the second reconnect triggers a - * fresh filesystem scan. - */ export const doubleOfflineCycleTest: TestDefinition = { - name: "Double Offline Cycle", description: - "Client 0 goes offline, edits, comes online, syncs. Then goes " + - "offline again, edits more, comes online again. Both offline edits " + - "must propagate to Client 1. Tests that runningReconciliation is " + - "properly cleared between cycles.", + "Client 0 goes through three offline-edit-reconnect cycles. " + + "Each offline edit must propagate to client 1 after reconnection.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, @@ -47,14 +16,8 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "initial" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "initial") }, - // First offline cycle: edit { type: "disable-sync", client: 0 }, { type: "update", @@ -63,18 +26,11 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "first edit" }, - // Come online, sync first edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "first edit" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "first edit") }, - // Second offline cycle: edit again { type: "disable-sync", client: 0 }, { type: "update", @@ -83,18 +39,11 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "second edit" }, - // Come online, sync second edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "second edit" - }, + { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "second edit") }, - // Third offline cycle: edit once more { type: "disable-sync", client: 0 }, { type: "update", @@ -103,10 +52,9 @@ export const doubleOfflineCycleTest: TestDefinition = { content: "third edit" }, - // Come online, sync third edit { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyAllEdits } + { type: "assert-consistent", verify: (s) => 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 index ed54b90d..f9ae2a3f 100644 --- 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 @@ -1,34 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Tests rename-overwrite behavior: when file A is renamed to file B's - * path (overwriting B), both clients should converge on a single file - * at the target path with A's content. - */ -function verifyOneFile(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${[...state.files.keys()].join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${[...state.files.keys()].join(", ")}` - ); - assert( - state.files.get("B.md") === "content A", - `Expected B.md to have A's content, got: "${state.files.get("B.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const failedVfsMoveFallsBackTest: TestDefinition = { - name: "Rename Overwrite — A.md Renamed to Occupied B.md", 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: [ - // Setup: create two files { 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 }, @@ -36,12 +13,10 @@ export const failedVfsMoveFallsBackTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 renames A.md to B.md (overwrite) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have only B.md - { type: "assert-consistent", verify: verifyOneFile } + { 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 7d5e524a..ce12df0c 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,55 +1,24 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyNoDuplicates(state: ClientState): void { - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "important data", - `Expected doc.md content to be "important data", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const idempotencyAfterServerPauseTest: TestDefinition = { - name: "Idempotency Key Prevents Duplicates After Server Pause", description: - "Client 0 creates a file. The server is paused mid-response (SIGSTOP), " + - "so the client's HTTP request stalls. When the server resumes, the " + - "idempotency key should prevent duplicate documents from being created. " + - "Both clients must converge to a single copy of the file.", + "Client 0 creates a file, then the server is paused mid-response. " + + "After the server resumes, both clients must converge to a single copy of the file with no duplicates.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates a file, then immediately pause the server so the - // response is stalled (the server may or may not have committed the - // create — either way the idempotency key protects us). { type: "create", client: 0, path: "doc.md", content: "important data" }, { type: "pause-server" }, - // Wait with server frozen — client's in-flight create request is stuck. - - // Resume the server. The stalled request completes (or the client - // retries with the same idempotency key). { type: "resume-server" }, - // Sync and converge { type: "sync" }, { type: "barrier" }, - // There must be exactly one doc.md with the correct content — no - // duplicates like "doc (1).md". - { type: "assert-consistent", verify: verifyNoDuplicates } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") } ] }; diff --git a/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts b/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts deleted file mode 100644 index 09fa5276..00000000 --- a/frontend/deterministic-tests/src/tests/interleaved-operations.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const interleavedOperationsTest: TestDefinition = { - name: "Interleaved Create-Update-Delete Across Clients", - description: - "Client 0 creates files A, B, C. Client 1 syncs. Then Client 0 deletes A, " + - "Client 1 updates B, Client 0 renames C to D — all interleaved. " + - "Both should converge to the same final state.", - clients: 2, - steps: [ - // Setup: create 3 files - { type: "create", client: 0, path: "A.md", content: "aaa" }, - { type: "create", client: 0, path: "B.md", content: "bbb" }, - { type: "create", client: 0, path: "C.md", content: "ccc" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Interleaved operations (both clients online) - { type: "delete", client: 0, path: "A.md" }, - { type: "update", client: 1, path: "B.md", content: "bbb-updated" }, - { type: "rename", client: 0, oldPath: "C.md", newPath: "D.md" }, - - { type: "sync" }, - { type: "barrier" }, - - // A.md deleted, B.md updated, C.md renamed to D.md - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-not-exists", client: 0, path: "C.md" }, - { type: "assert-not-exists", client: 1, path: "C.md" }, - { type: "assert-exists", client: 0, path: "D.md" }, - { type: "assert-exists", client: 1, path: "D.md" }, - { type: "assert-consistent" } - ] -}; 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 438af856..ef8404fb 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -1,48 +1,25 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX TEST: Interrupted deletes must be retried after reconnect. - * - * Scenario: - * 1. Client 0 creates a file, syncs to both clients. - * 2. Client 0 deletes the file. - * 3. Server is paused BEFORE the delete HTTP request completes. - * The doc transitions to deleted-locally but the server never receives the delete. - * 4. Server resumes. Client reconnects and runs reconciliation. - * 5. The interrupted delete should be retried and succeed. - * 6. Both clients should converge on 0 files. - */ -function verifyNoFiles(state: ClientState): void { - assert(state.files.size === 0, `Expected 0 files, got ${state.files.size}: ${[...state.files.keys()].join(", ")}`); -} +import type { TestDefinition } from "../test-definition"; export const interruptedDeleteRetryTest: TestDefinition = { - name: "Interrupted Delete Is Retried After Reconnect", description: - "A delete that was interrupted by a server pause/disconnect " + - "should be retried when the connection is restored.", + "Client 0 deletes a file, then the server is paused. " + + "After the server resumes, both clients should have zero files.", clients: 2, steps: [ - // Setup: create file, sync both { 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" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "doc.md" }, - // Pause server to interrupt the delete request { type: "pause-server" }, - // Resume server - the interrupted delete should be retried { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have 0 files - { type: "assert-consistent", verify: verifyNoFiles }, + { type: "assert-consistent", verify: (s) => 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 d85ddfbc..9d9a870d 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,45 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Queue key migration can drop events when the new key already has events. - * - * In sync-event-queue.ts line 94-98, migrateKey() silently drops events - * from the old key if the new key (documentId) already has queued events. - * The comment says "Keep the existing state at the new key (it's more - * recent)" — but the old key's state may contain unsynced local changes. - * - * Scenario: - * 1. Client creates file A.md (pending, key = "path:A.md") - * 2. Server assigns documentId via resolveIdempotencyKeys - * 3. BEFORE the key migration, a local-update event for A.md arrives - * and gets queued under "path:A.md" (because the doc is still pending - * at that point in the resolveKey lookup) - * 4. Meanwhile, a remote-update broadcast arrives for the same documentId - * and gets queued under the documentId key - * 5. migrateKey runs: old key has "update", new key has "remote-update" - * 6. The old key's "update" is DROPPED — the local edit is lost - * - * This test simulates a similar scenario: Client 0 creates a file and - * immediately updates it. While the create is being resolved, the update - * should not be lost. - */ -function verifyUpdatedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content === "updated content", - `Expected "updated content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const keyMigrationEventDropTest: TestDefinition = { - name: "Key Migration Does Not Drop Local Updates", description: - "Client creates a file and immediately updates it before the create " + - "is acknowledged. The queue key migrates from path-based to documentId. " + - "The local update should not be lost during key migration.", + "Client 0 creates a file and immediately updates it while the server is paused. " + + "After resume, both clients should have the updated content.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -47,10 +11,8 @@ export const keyMigrationEventDropTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create request stalls { type: "pause-server" }, - // Client 0 creates file, then immediately updates it { type: "create", client: 0, @@ -64,12 +26,10 @@ export const keyMigrationEventDropTest: TestDefinition = { content: "updated content" }, - // Resume server — create completes, update should follow { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // The updated content should be on both clients, not the initial - { type: "assert-consistent", verify: verifyUpdatedContent } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") } ] }; diff --git a/frontend/deterministic-tests/src/tests/large-file-count.test.ts b/frontend/deterministic-tests/src/tests/large-file-count.test.ts deleted file mode 100644 index a295a10a..00000000 --- a/frontend/deterministic-tests/src/tests/large-file-count.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { ClientState, TestDefinition, TestStep } from "../test-definition"; -import { assert } from "../utils/assert"; - -const FILE_COUNT = 20; - -function buildSteps(): TestStep[] { - const steps: TestStep[] = []; - - // Create N files offline on client 0 - for (let i = 0; i < FILE_COUNT; i++) { - steps.push({ - type: "create", - client: 0, - path: `file-${String(i).padStart(3, "0")}.md`, - content: `content-${i}` - }); - } - - // Enable sync and converge - steps.push({ type: "enable-sync", client: 0 }); - steps.push({ type: "enable-sync", client: 1 }); - steps.push({ type: "sync" }); - steps.push({ type: "barrier" }); - - // Verify all files - steps.push({ - type: "assert-consistent", - verify: (state: ClientState) => { - assert( - state.files.size === FILE_COUNT, - `Expected ${FILE_COUNT} files, got ${state.files.size}` - ); - for (let i = 0; i < FILE_COUNT; i++) { - const path = `file-${String(i).padStart(3, "0")}.md`; - assert(state.files.has(path), `Missing file: ${path}`); - assert( - state.files.get(path) === `content-${i}`, - `Wrong content for ${path}` - ); - } - } - }); - - return steps; -} - -export const largeFileCountTest: TestDefinition = { - name: "Large File Count Sync", - description: - `Client 0 creates ${FILE_COUNT} files offline. All should sync ` + - "to Client 1 with correct content.", - clients: 2, - steps: buildSteps() -}; 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 4ab69ba8..94d82baa 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,53 +1,16 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Local edit lost when create returns MergingUpdate. - * - * Scenario: - * 1. Client 1 creates doc.md and syncs it to the server - * 2. Client 0 (offline) creates doc.md with different content - * 3. Server is paused, client 0 goes online — create request stalls - * 4. Client 0 updates the file locally while the create is in-flight - * 5. Server resumes → create returns MergingUpdate with merged content - * 6. applyServerResponse reads currentDisk (the local update) and calls - * write(path, currentDisk, responseBytes). The 3-way merge sees - * parent == ours (currentDisk == currentDisk) → "no local changes" → - * overwrites with server content. The local update is permanently lost. - * - * Expected: the local edit made during the in-flight create must survive. - */ -function verifyLocalEditPreserved(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("from-client-1"), - `Expected "from-client-1" in content, got: "${content}"` - ); - // The critical assertion: the local edit made while the create was - // in-flight must survive the MergingUpdate 3-way merge. - assert( - content.includes("local-edit-during-create"), - `Expected "local-edit-during-create" in content (lost during merge), got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const localEditLostDuringCreateMergeTest: TestDefinition = { - name: "Local Edit Lost During Create-Merge Response", description: - "When a create returns a MergingUpdate and the file was locally " + - "edited between the request and response, the local edit must " + - "not be lost by the 3-way merge.", + "Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " + + "Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.", clients: 2, steps: [ - // Client 1 creates doc.md while client 0 is offline { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" }, { type: "sync", client: 1 }, - // Client 0 creates the same file offline (doesn't know about client 1's version) { type: "create", client: 0, @@ -55,13 +18,10 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "from-client-0" }, - // Pause server so client 0's create stalls mid-flight { type: "pause-server" }, - // Bring client 0 online — its create request will stall { type: "enable-sync", client: 0 }, - // Client 0 updates the file WHILE the create is in-flight { type: "update", client: 0, @@ -69,16 +29,12 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "local-edit-during-create" }, - // Resume server — create completes with MergingUpdate { type: "resume-server" }, - // Give time for: create response → 3-way merge → follow-up - // update (detects local edit) → propagation to client 1 { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // The local edit must be preserved - { type: "assert-consistent", verify: verifyLocalEditPreserved } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") } ] }; 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 ef9b65c1..ce991df3 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,109 +1,45 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Edge case: Both clients create files at DIFFERENT paths, then both rename - * their respective files to the SAME target path. - * - * Timeline: - * 1. Client 0 creates X.md, Client 1 creates Y.md (both offline). - * 2. Both enable sync, converge (X.md and Y.md exist on both). - * 3. Client 1 goes offline. - * 4. Client 0 renames X.md -> Z.md, syncs. - * 5. Client 1 (offline) renames Y.md -> Z.md. - * 6. Client 1 reconnects. - * - * The tricky part: Both renames target Z.md. Client 0's rename completes first - * on the server. When Client 1 reconnects and tries to rename Y.md -> Z.md, - * the server already has a document at Z.md (formerly X.md). The system must - * use path deconfliction (e.g., Z (1).md) to preserve both documents' content. - * - * This differs from the existing concurrent-rename-same-target test because - * the files START at different paths (not A.md/B.md created by the same client) - * and the creates themselves are concurrent, exercising the interaction between - * concurrent create-merge and rename-deconfliction. - */ - -function verifyBothContentsPreserved(state: ClientState): void { - const allContent = Array.from(state.files.values()).join("\n"); - assert( - allContent.includes("content-x"), - `Expected "content-x" to be preserved somewhere. ` + - `Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); - assert( - allContent.includes("content-y"), - `Expected "content-y" to be preserved somewhere. ` + - `Files: ${JSON.stringify(Object.fromEntries(state.files))}` - ); - - // Neither X.md nor Y.md should exist (both were renamed away) - assert( - !state.files.has("X.md"), - `Expected X.md to not exist (was renamed). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - !state.files.has("Y.md"), - `Expected Y.md to not exist (was renamed). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // At least one file should be at Z.md - assert( - state.files.has("Z.md"), - `Expected Z.md to exist. ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // There must be exactly 2 files (both contents preserved, possibly deconflicted) - assert( - state.files.size === 2, - `Expected exactly 2 files, got ${state.files.size}: ` + - Array.from(state.files.keys()).join(", ") - ); -} +import type { TestDefinition } from "../test-definition"; export const mcCrossCreateRenameSameTargetTest: TestDefinition = { - name: "MC: Cross-Create then Rename to Same Target", description: "Client 0 creates X.md, Client 1 creates Y.md. Both sync. Client 0 renames " + "X.md -> Z.md. Client 1 (offline) renames Y.md -> Z.md. Both must converge " + "with both contents preserved via path deconfliction.", clients: 2, steps: [ - // Phase 1: Both create files offline at different paths { type: "create", client: 0, path: "X.md", content: "content-x" }, { type: "create", client: 1, path: "Y.md", content: "content-y" }, - // Both enable sync — creates race to server { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Verify both files exist on both clients - { type: "assert-exists", client: 0, path: "X.md" }, - { type: "assert-exists", client: 0, path: "Y.md" }, - { type: "assert-exists", client: 1, path: "X.md" }, - { type: "assert-exists", client: 1, path: "Y.md" }, + { + type: "assert-consistent", + verify: (s) => s.assertFileExists("X.md").assertFileExists("Y.md") + }, - // Phase 2: Client 1 goes offline { type: "disable-sync", client: 1 }, - // Phase 3: Client 0 renames X.md -> Z.md and syncs { type: "rename", client: 0, oldPath: "X.md", newPath: "Z.md" }, { type: "sync", client: 0 }, - // Phase 4: Client 1 (offline) renames Y.md -> Z.md { type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" }, - // Phase 5: Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both contents must be preserved, both clients consistent - { type: "assert-consistent", verify: verifyBothContentsPreserved } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(2) + .assertFileNotExists("X.md") + .assertFileNotExists("Y.md") + .assertFileExists("Z.md") + .assertAnyFileContains("content-x", "content-y"); + } + } ] }; 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 e5f8f362..98504f03 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,98 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * Edge case: Client 0 creates a file, syncs. Client 1 receives it. Then Client - * 0 deletes the file and syncs. Meanwhile Client 1 goes offline and renames it. - * - * Timeline: - * 1. Client 0 creates A.md, both sync. - * 2. Client 1 goes offline. - * 3. Client 0 deletes A.md, syncs (server marks document as deleted). - * 4. Client 1 (offline) renames A.md -> B.md. - * 5. Client 1 reconnects. - * - * The tricky part: Client 1's rename targets a document that was deleted on the - * server between Client 1's disconnect and reconnect. The offline rename is a - * sync-update with oldPath=A.md, relativePath=B.md. On reconnect, the offline - * reconciliation detects B.md as a local file with a documentId pointing to a - * deleted server document. The system must decide: honor the rename (creating a - * new document at B.md) or propagate the delete. - * - * This test verifies that both clients converge regardless of which resolution - * strategy the system uses, and that no data is silently lost without the other - * client also seeing the same result. - * - * We also add a second file C.md that remains untouched to verify unrelated - * documents are not affected by the conflict resolution. - */ - -function verifyState(state: ClientState): void { - // C.md must always survive (unrelated to the conflict) - assert( - state.files.has("C.md"), - `Expected C.md to exist (untouched). ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("C.md") === "unrelated", - `Expected C.md content to be "unrelated", got: "${state.files.get("C.md")}"` - ); - - // A.md should NOT exist (it was either renamed or deleted) - assert( - !state.files.has("A.md"), - `Expected A.md to NOT exist. ` + - `Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Either B.md exists (rename won) or no extra files exist (delete won). - // The key invariant is convergence, which assert-consistent already checks. - // But let's also verify that the content is correct if B.md exists. - if (state.files.has("B.md")) { - const content = state.files.get("B.md") ?? ""; - assert( - content === "original", - `If B.md exists (rename won), it should have the original content. Got: "${content}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const mcDeleteThenOfflineRenameTest: TestDefinition = { - name: "MC: Delete Synced Then Offline Rename", description: "Client 0 creates A.md, both sync. Client 1 goes offline. Client 0 deletes " + "A.md and syncs. Client 1 (offline) renames A.md to B.md. Client 1 reconnects. " + "Both must converge. C.md (unrelated) must be unaffected.", clients: 2, steps: [ - // Phase 1: Client 0 creates A.md and C.md, both sync { type: "create", client: 0, path: "A.md", content: "original" }, { type: "create", client: 0, path: "C.md", content: "unrelated" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "original" }, - { type: "assert-content", client: 1, path: "C.md", content: "unrelated" }, - // Phase 2: Client 1 goes offline { type: "disable-sync", client: 1 }, - // Phase 3: Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - // Phase 4: Client 1 (offline) renames A.md -> B.md { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, - // Phase 5: Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both must converge — key assertions - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertContent("C.md", "unrelated") + .assertFileNotExists("A.md"); + s.ifFileExists("B.md", (s) => s.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 4ebe131b..26a095d5 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,43 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // file-1.md, file-3.md, file-5.md must survive (unaffected by conflict) - for (const path of ["file-1.md", "file-3.md", "file-5.md"]) { - assert( - state.files.has(path), - `Expected ${path} to exist. Files: ${files.join(", ")}` - ); - } - - // file-2.md was deleted on server by Client 1, and renamed to - // renamed.md by Client 0 offline. The delete should win. - assert( - !state.files.has("file-2.md"), - `Expected file-2.md to be deleted. Files: ${files.join(", ")}` - ); - - // file-4.md was also deleted by Client 1. - assert( - !state.files.has("file-4.md"), - `Expected file-4.md to be deleted. Files: ${files.join(", ")}` - ); - - // renamed.md: Client 0's offline rename of deleted file-2.md. - // The delete is authoritative, so renamed.md may or may not exist - // depending on conflict resolution. If it exists, verify its content. - if (state.files.has("renamed.md")) { - assert( - state.files.get("renamed.md") === "content-2", - `If renamed.md exists, it should have "content-2", got: "${state.files.get("renamed.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const mcMultiDeleteOfflineRenameTest: TestDefinition = { - name: "MC: Multi-File Delete + Offline Rename", description: "Client 0 creates 5 files. Client 1 deletes 2 while Client 0 (offline) " + "renames one of the deleted files. Both must converge.", @@ -53,23 +16,28 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 1 deletes file-2 and file-4 { type: "delete", client: 1, path: "file-2.md" }, { type: "delete", client: 1, path: "file-4.md" }, { type: "sync", client: 1 }, - // Client 0 (offline) renames file-2 { type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both must converge - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + 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")); + } + } ] }; 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 23dbb02d..8144bbb5 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,39 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - // A.md should not exist (it was renamed to B.md by Client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Exactly 1 file should exist (B.md with merged content) - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - // B.md must exist with Client 2's updated content merged in - assert( - state.files.has("B.md"), - `Expected B.md to exist. Files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("updated-by-client-2"), - `Expected B.md to contain "updated-by-client-2", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { - name: "MC: Three-Client Rename + Offline Update", description: "Client 0 creates A.md. Client 1 renames to B.md. Client 2 (offline) " + "updates A.md. All three converge with updated content at B.md.", clients: 3, steps: [ - // Phase 1: Client 0 creates A.md, everyone syncs { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, @@ -41,26 +13,18 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Phase 2: Client 2 goes offline { type: "disable-sync", client: 2 }, - // Phase 3: Client 1 renames A.md -> B.md, clients 0 and 1 sync { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 1 }, { type: "sync", client: 0 }, - // Don't use barrier here — Client 2 is offline and can't converge - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - // Phase 4: Client 2 updates its local A.md while offline { type: "update", client: 2, path: "A.md", content: "updated-by-client-2" }, - // Phase 5: Client 2 reconnects { type: "enable-sync", client: 2 }, { type: "sync" }, { type: "barrier" }, - // All three must converge - { type: "assert-consistent", verify: verifyState } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") } ] }; 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 ba9a50ae..a4f6d3d3 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,35 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: migrateKey must not overwrite existing state at the new key. - * - * Scenario: - * 1. Client 0 creates file A.md, then immediately updates it - * 2. Server is paused so the create stalls (idempotency key unresolved) - * 3. Client 1 is online and also creates at A.md (different content) - * 4. Server resumes — both creates merge - * 5. Client 0's update should not be lost during key migration - * - * The test verifies that after convergence, the file exists with - * content from both clients' edits. - */ -function verifyContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - // Client 0's update should be present - assert( - content.includes("updated by client 0"), - `Expected content to include "updated by client 0", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const migrateKeyPreservesExistingTest: TestDefinition = { - name: "Key Migration Preserves Existing Queue State", description: - "When migrateKey is called and the new key already has queued " + - "events, the existing events must not be silently dropped.", + "Client 0 creates a file and immediately updates it while the server is paused. " + + "After resume, the update must not be lost.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -37,10 +11,8 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create stalls { type: "pause-server" }, - // Client 0 creates and immediately updates { type: "create", client: 0, path: "A.md", content: "initial" }, { type: "update", @@ -49,11 +21,10 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { content: "updated by client 0" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContent } + { type: "assert-consistent", verify: (s) => 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 6430b796..f590f5b4 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,53 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyBothContentAndPath(state: ClientState): void { - // The file should be at B.md (Client 0 renamed it) - // AND should contain Client 1's updated content (merged with original) - const files = Array.from(state.files.keys()); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${files.join(", ")}` - ); - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); - - const content = state.files.get("B.md") ?? ""; - // Client 1 updated the content to include "updated by client 1" - // The 3-way merge should preserve this update at the renamed path - assert( - content.includes("updated by client 1"), - `Expected B.md to contain "updated by client 1" from the remote update, got: "${content}"` - ); -} - -/** - * BUG: Coalescing table says `move + remote-update = move`, which drops - * the remote update content. The local client only sends the rename - * to the server. If the server has no concurrent version to merge with, - * the remote client's update is lost on this client until a forced - * re-sync (runFinalConsistencyCheck). - * - * This test verifies that when Client 0 renames A.md → B.md while - * Client 1 simultaneously updates A.md, BOTH the rename and the - * content update are reflected on both clients. - */ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { - name: "Move and Concurrent Remote Update", description: - "Client 0 renames A.md to B.md while Client 1 updates A.md content. " + - "The coalescing table merges move + remote-update into just 'move', " + - "potentially dropping the remote content update. Both clients should " + - "converge to B.md with Client 1's updated content.", + "Client 0 renames A.md to B.md offline while client 1 updates A.md. " + + "After client 0 reconnects, both should have B.md with client 1's updated content.", clients: 2, steps: [ - // Setup: both clients share A.md { type: "create", client: 0, @@ -58,18 +16,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" - }, - // Client 0 goes offline and renames A.md → B.md { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - // Client 1 updates A.md while Client 0 is offline { type: "update", client: 1, @@ -78,12 +28,10 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online — will receive remote-update for A.md - // The move event (A→B) and remote-update should both apply { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyBothContentAndPath } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated by client 1") } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts deleted file mode 100644 index b4be03d9..00000000 --- a/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Three-file circular rotation while offline. - * - * Files A, B, C get rotated: A→B, B→C, C→A. Since the DeterministicAgent - * works on an in-memory filesystem, we can simulate this by: - * 1. Delete all three files - * 2. Recreate them with rotated content - * - * On reconnect, the reconciliation algorithm must detect that: - * - A.md has C's old content (move from C→A) - * - B.md has A's old content (move from A→B) - * - C.md has B's old content (move from B→C) - * - * Since each file has unique content, the hash-based move detection should - * work. But this creates THREE simultaneous move detections, which is a - * stress test of the algorithm: each match removes from missingTracked, - * and the order of processing matters. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 3, - `Expected 3 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.get("A.md") === "was C", - `Expected A.md = "was C", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "was A", - `Expected B.md = "was A", got: "${state.files.get("B.md")}"` - ); - assert( - state.files.get("C.md") === "was B", - `Expected C.md = "was B", got: "${state.files.get("C.md")}"` - ); -} - -export const moveChainThreeFilesTest: TestDefinition = { - name: "Three-File Circular Rotation Offline", - description: - "Three files are rotated (A→B, B→C, C→A) while offline by " + - "deleting all and recreating with swapped content. The reconciliation " + - "should detect the moves via hash matching and sync correctly.", - clients: 2, - steps: [ - // Setup: create three files with unique content - { type: "create", client: 0, path: "A.md", content: "was A" }, - { type: "create", client: 0, path: "B.md", content: "was B" }, - { type: "create", client: 0, path: "C.md", content: "was C" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Client 0 goes offline - { type: "disable-sync", client: 0 }, - - // Delete all three - { type: "delete", client: 0, path: "A.md" }, - { type: "delete", client: 0, path: "B.md" }, - { type: "delete", client: 0, path: "C.md" }, - - // Recreate with rotated content: C→A, A→B, B→C - { type: "create", client: 0, path: "A.md", content: "was C" }, - { type: "create", client: 0, path: "B.md", content: "was A" }, - { type: "create", client: 0, path: "C.md", content: "was B" }, - - // Reconnect - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-consistent", verify: verifyFinalState } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts deleted file mode 100644 index 39b1c61d..00000000 --- a/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Move detection fails when two files have identical content. - * - * reconcileWithDisk() detects moves by matching content hashes of new files - * against missing tracked docs. If there are TWO missing tracked docs with - * the same hash, neither will match (matches.length !== 1), and the move - * is treated as a "new file + delete" instead of a rename. - * - * Scenario: - * 1. Client 0 creates two files with identical content: A.md and B.md - * 2. Both sync to Client 1 - * 3. Client 1 goes offline - * 4. Client 1 deletes A.md and renames B.md to C.md (same content) - * 5. Client 1 reconnects - * - * Expected: A.md deleted on server, B.md renamed to C.md (preserving documentId) - * Bug: reconcileWithDisk sees B.md missing + C.md new, but content hash - * matches BOTH A.md and B.md (since they had identical content). So the - * move from B→C is not detected. Instead, B.md is treated as a delete - * and C.md as a new create, losing B.md's documentId. - * - * The test verifies convergence still works (the system recovers via - * server-side merge), but documents may get new documentIds unnecessarily. - */ -function verifyFinalState(state: ClientState): void { - // A.md should not exist (deleted) - assert(!state.files.has("A.md"), "A.md should not exist"); - - // B.md should not exist (renamed to C.md) - assert(!state.files.has("B.md"), "B.md should not exist"); - - // C.md should exist with the shared content - assert(state.files.has("C.md"), "C.md should exist"); - const content = state.files.get("C.md") ?? ""; - assert( - content === "identical content", - `Expected C.md to contain "identical content", got: "${content}"` - ); - - // Only C.md should exist - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} - -export const moveIdenticalContentAmbiguityTest: TestDefinition = { - name: "Move Detection Ambiguity With Identical Content", - description: - "Two files with identical content exist. One is deleted and the other " + - "renamed while offline. On reconnect, the move detection algorithm sees " + - "two matching hashes and cannot determine which missing doc was moved. " + - "The system should still converge correctly.", - clients: 2, - steps: [ - // Setup: create two files with identical content - { - type: "create", - client: 0, - path: "A.md", - content: "identical content" - }, - { - type: "create", - client: 0, - path: "B.md", - content: "identical content" - }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Verify both clients have both files - { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "identical content" - }, - - // Client 1 goes offline, deletes A.md and renames B.md → C.md - { type: "disable-sync", client: 1 }, - { type: "delete", client: 1, path: "A.md" }, - { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, - - // Client 1 reconnects - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - // Both clients should converge - { type: "assert-consistent", verify: verifyFinalState } - ] -}; 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 b5c225b5..59bedbbe 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,59 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Local rename must not drop a concurrent remote content update. - * - * Scenario: - * 1. Both clients have doc.md = "line 1\nline 2" - * 2. Client 0 renames doc.md to renamed.md - * 3. Client 1 edits doc.md content - * 4. Both sync - * 5. The file should exist (at some path) with both the rename and content update applied - */ -function verifyContentPreserved(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - // The file should be at the renamed path - assert( - state.files.has("renamed.md") || state.files.has("doc.md"), - `Expected file at renamed.md or doc.md, got: ${Array.from(state.files.keys()).join(", ")}` - ); - // Content from client 1's edit should be present - const [content] = [...state.files.values()]; - assert( - content.includes("client 1 edit"), - `Expected merged content to include "client 1 edit", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const movePreservesRemoteUpdateTest: TestDefinition = { - name: "Local Move Preserves Remote Content Update", description: - "When a user renames a file and another client edits it concurrently, " + - "the content update should not be lost.", + "Client 0 renames a file offline while client 1 edits it offline. " + + "After both reconnect, the renamed file should contain client 1's edit.", clients: 2, steps: [ - // Setup { 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" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 renames, client 1 edits content { 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" }, - // Both come online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyContentPreserved }, + { + type: "assert-consistent", + verify: (s) => { + s.assertFileCount(1); + const content = Array.from(s.files.values())[0]; + if (!content.includes("client 1 edit")) { + 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 6bbbca29..95fcfe26 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,71 +1,35 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: remote-update + local-move = remote-update loses the rename. - * - * In sync-events.ts coalesceFromRemoteUpdate (line 271-272): - * case "local-move": - * return current; // remote-update absorbs the local-move - * - * When a remote-update broadcast arrives and then the user renames the - * file, the coalescing discards the move info. The executor only sees - * "remote-update" and calls executeSyncUpdateFull(force=true). - * - * In the force path (no local content changes), the server responds - * with the old path. The client moves the file BACK to the old path, - * reverting the user's rename. - * - * If there ARE content changes, the update sends doc.relativePath (the - * new path) to the server, which may preserve the rename. But the - * behavior is inconsistent. - * - * This test verifies that when a remote-update and a local-rename race, - * the rename is preserved (or at least both clients converge). - */ -function verifyState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - // The file should exist at the renamed path or original — either is OK - // as long as both clients converge. But ideally the rename survives. - const content = Array.from(state.files.values())[0]; - assert( - content === "updated by client 1", - `Expected "updated by client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { - name: "Remote Update + Local Move Coalescing May Revert Rename", description: - "When a remote-update broadcast arrives and the user renames the " + - "file, the coalescing (remote-update + local-move = remote-update) " + - "discards the rename info. The force path may revert the rename " + - "by moving the file back to the server's path.", + "Client 1 updates a file while client 0 is offline. Client 0 reconnects and renames the file. " + + "Both clients should converge with client 1's updated content.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 1 updates the file content (broadcasts to client 0) { type: "disable-sync", client: 0 }, { type: "update", client: 1, path: "doc.md", content: "updated by client 1" }, { type: "sync", client: 1 }, - // Client 0 comes online and renames the file while the remote-update - // is arriving on the WebSocket { type: "enable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, { type: "sync" }, { type: "barrier" }, - // Both should converge - { type: "assert-consistent", verify: verifyState } + { + 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}"`); + } + } + } ] }; 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 c207d0a9..77814669 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,43 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyDeleted(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 0, - `Expected 0 files after move+delete, got ${state.files.size}: ${files.join(", ")}` - ); -} - -/** - * Tests the stale-path bug in the delete executor. - * - * When a file is renamed (A→B) and then deleted, the event coalescing - * produces `move(A→B) + delete = delete(path: A)`. The VFS.move in - * syncLocallyUpdatedFile has already moved the doc to B. The executor's - * delete action looks up the doc: getByPath("A") returns undefined - * (doc moved to B), so it falls back to getByDocumentId. It finds the - * doc at B. Then it calls deleteLocally(). - * - * Before the fix: deleteLocally(action.path) used "A" — the stale - * path from when the event was enqueued. The pathIndex lookup at "A" - * fails (doc is at "B"), so the delete is silently dropped. The doc - * stays tracked at B, and the file is gone from disk but VFS thinks - * it still exists. - * - * After the fix: deleteLocally(doc.relativePath) uses "B" — the - * current VFS path. The delete succeeds. - */ export const moveThenDeleteStalePathTest: TestDefinition = { - name: "Move Then Delete (Stale Path Fix)", description: - "Client 0 creates A.md, syncs. Then renames A.md to B.md and " + - "immediately deletes B.md. The coalesced delete action has the " + - "old path 'A', but the doc is at 'B' in VFS. The delete executor " + - "must use the current VFS path, not the stale action path.", + "Client 0 renames A.md to B.md and immediately deletes B.md. " + + "Both clients should end up with zero files.", clients: 2, steps: [ - // Setup: create and sync { type: "create", client: 0, @@ -48,25 +16,13 @@ export const moveThenDeleteStalePathTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { - type: "assert-content", - client: 1, - path: "A.md", - content: "content to delete" - }, - // Rename A→B then delete B (with sync enabled so VFS.move fires) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "delete", client: 0, path: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have 0 files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyDeleted } + { type: "assert-consistent", verify: (s) => 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 827e7f77..66efd778 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -1,53 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyState(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md must exist with updated content from client 1 - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - const bContent = state.files.get("B.md") ?? ""; - assert( - bContent.includes("updated"), - `Expected B.md to contain "updated", got: "${bContent}"` - ); - - // C.md must exist (created independently, unaffected) - assert( - state.files.has("C.md"), - `Expected C.md to exist, got: ${files.join(", ")}` - ); - - // A.md should not exist (deleted by client 0 or renamed by client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist, got: ${files.join(", ")}` - ); - - // D.md: Client 1 renamed the server-deleted A.md to D.md offline. - // The system may keep D.md (rename wins) or drop it (delete wins). - // If D.md exists, it should have the original content. - if (state.files.has("D.md")) { - assert( - state.files.get("D.md") === "content-a", - `If D.md exists, it should have "content-a", got: "${state.files.get("D.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const multiFileOperationsTest: TestDefinition = { - name: "Multi-File Operations", description: - "Client 0 creates A.md, B.md, C.md. Both clients sync. Client 1 goes offline. " + - "Client 0 deletes A.md. Client 1 (offline) updates B.md and renames A.md to D.md. " + - "When Client 1 reconnects, the system must reconcile: A.md deleted on server, " + - "renamed on client 1; B.md updated on client 1. Both must converge.", + "Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " + + "After client 1 reconnects, both clients must converge with B.md updated and C.md intact.", clients: 2, steps: [ - // Setup: create three files and sync { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "C.md", content: "content-c" }, @@ -56,23 +14,26 @@ export const multiFileOperationsTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md and syncs { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) updates B.md and renames A.md to D.md { type: "update", client: 1, path: "B.md", content: "updated by client 1" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Verify convergence: B.md and C.md must exist. B.md must have update. - { type: "assert-consistent", verify: verifyState } + { + type: "assert-consistent", + verify: (s) => { + s.assertContains("B.md", "updated") + .assertFileExists("C.md") + .assertFileNotExists("A.md"); + s.ifFileExists("D.md", (s) => s.assertContent("D.md", "content-a")); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts b/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts deleted file mode 100644 index ba4de977..00000000 --- a/frontend/deterministic-tests/src/tests/multiple-updates-coalesce.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const multipleUpdatesCoalesceTest: TestDefinition = { - name: "Multiple Rapid Updates Converge to Final Version", - description: - "Client 0 rapidly updates a file multiple times while online. " + - "Both clients must converge to the final content.", - clients: 2, - steps: [ - // Setup: create file and sync - { type: "create", client: 0, path: "rapid.md", content: "v0" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "rapid.md", content: "v0" }, - - // Client 0 rapidly updates (sync is enabled, so events are enqueued) - { type: "update", client: 0, path: "rapid.md", content: "v1" }, - { type: "update", client: 0, path: "rapid.md", content: "v2" }, - { type: "update", client: 0, path: "rapid.md", content: "v3" }, - { type: "update", client: 0, path: "rapid.md", content: "v4-final" }, - - // Sync and converge - { type: "sync" }, - { type: "barrier" }, - - // Both should have the final version - { - type: "assert-content", - client: 0, - path: "rapid.md", - content: "v4-final" - }, - { - type: "assert-content", - client: 1, - path: "rapid.md", - content: "v4-final" - }, - { type: "assert-consistent" } - ] -}; 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 3e5dc3ca..56ecc00d 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -1,41 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // The original file A.md should not exist (both clients renamed it away) - assert( - !state.files.has("A.md"), - `A.md should not exist after both renames. Files: ${files.join(", ")}` - ); - - // Both clients renamed the same document. The server picks one rename - // as the winner. Exactly one file should exist (the document at its - // final path) since there was only one document to begin with. - assert( - state.files.size === 1, - `Expected exactly 1 file (same document renamed), got ${state.files.size}: ${files.join(", ")}` - ); - - // The rename target should be B.md or C.md - const hasB = state.files.has("B.md"); - const hasC = state.files.has("C.md"); - assert( - hasB || hasC, - `Expected B.md or C.md to exist. Files: ${files.join(", ")}` - ); - - // The content must be preserved regardless of which rename won - const [content] = Array.from(state.files.values()); - assert( - content === "shared-content", - `Expected content "shared-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineConcurrentRenamesTest: TestDefinition = { - name: "Offline Concurrent Renames of Same File", description: "Client 0 creates A.md and syncs to both clients. Both clients go offline. " + "Client 0 renames A.md to B.md. Client 1 renames A.md to C.md. " + @@ -43,24 +8,19 @@ export const offlineConcurrentRenamesTest: TestDefinition = { "agree on the final state and the content must not be lost.", clients: 2, steps: [ - // Setup: create A.md and sync to both clients { 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-content", - client: 1, - path: "A.md", - content: "shared-content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "shared-content") }, - // Both clients go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 renames A.md -> B.md { type: "rename", client: 0, @@ -68,7 +28,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = { newPath: "B.md" }, - // Client 1 renames A.md -> C.md { type: "rename", client: 1, @@ -76,17 +35,24 @@ export const offlineConcurrentRenamesTest: TestDefinition = { newPath: "C.md" }, - // Both reconnect { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // A.md must be gone from both - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - - // Both must converge to the same state with content preserved - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileNotExists("A.md") + .assertFileCount(1) + .assertAnyFileContains("shared-content"); + s.ifFileExists("B.md", (s) => + s.assertContent("B.md", "shared-content") + ); + s.ifFileExists("C.md", (s) => + s.assertContent("C.md", "shared-content") + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts b/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts deleted file mode 100644 index 28a25cce..00000000 --- a/frontend/deterministic-tests/src/tests/offline-create-rename-create.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesExist(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // B.md should exist with the original content (renamed from A.md) - assert( - state.files.has("B.md"), - `B.md should exist (renamed from A.md). Files: ${files.join(", ")}` - ); - const bContent = state.files.get("B.md") ?? ""; - assert( - bContent === "first-content", - `B.md should have "first-content" (original file), got: "${bContent}"` - ); - - // A.md should exist with the new content (recreated after rename) - assert( - state.files.has("A.md"), - `A.md should exist (recreated after rename). Files: ${files.join(", ")}` - ); - const aContent = state.files.get("A.md") ?? ""; - assert( - aContent === "second-content", - `A.md should have "second-content" (new file), got: "${aContent}"` - ); - - // Exactly 2 files - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${files.join(", ")}` - ); -} - -export const offlineCreateRenameCreateTest: TestDefinition = { - name: "Offline Create, Rename, Recreate Same Path", - description: - "Client 0 goes offline. Creates file A with content X, renames A to B, " + - "then creates a new file A with content Y. When Client 0 reconnects, " + - "Client 1 should see both A.md (content Y) and B.md (content X) -- " + - "the rename and the new create are independent documents.", - clients: 2, - steps: [ - // Client 1 starts syncing immediately to receive updates - { type: "enable-sync", client: 1 }, - - // Client 0 is offline and performs create -> rename -> create - { type: "create", client: 0, path: "A.md", content: "first-content" }, - { - type: "rename", - client: 0, - oldPath: "A.md", - newPath: "B.md" - }, - { type: "create", client: 0, path: "A.md", content: "second-content" }, - - // Client 0 enables sync -- offline reconciliation should detect - // B.md and A.md as two separate new files - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - // Both files should exist on both clients - { type: "assert-exists", client: 0, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyBothFilesExist } - ] -}; 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 b43f1287..ca777563 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,52 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Two clients create at the same path while offline — mergeable text files. - * - * When a remote-update arrives for a path where a local pending create - * exists, the code at sync-actions.ts line 1161 skips the remote download - * ONLY for mergeable file types. For mergeable files, the idempotency - * key resolution will handle the merge correctly. - * - * This test verifies that when both clients create at the same path with - * different text content while offline, the server merges correctly and - * both clients converge. - * - * The interesting edge case is: Client 0 creates and syncs first, then - * Client 1 creates at the same path. The server's smart create should - * merge the content (3-way merge with empty parent), and both clients - * should see both pieces of content. - */ -function verifyMergedContent(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("notes.md"), - `Expected notes.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("notes.md") ?? ""; - assert( - content.includes("alpha wrote this line"), - `Expected content to include "alpha wrote this line", got: "${content}"` - ); - assert( - content.includes("beta wrote this different line"), - `Expected content to include "beta wrote this different line", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineCreateSamePathMergeableTest: TestDefinition = { - name: "Offline Create Same Path — Mergeable Text", description: - "Both clients create a file at the same path while offline with " + - "different text content. When both sync, the server should 3-way " + - "merge the content and both clients should converge to the merged result.", + "Both clients create a file at the same path while offline with different text content. " + + "After both sync, both clients must converge to a merged result containing both contributions.", clients: 2, steps: [ - // Both clients create at same path while offline { type: "create", client: 0, @@ -60,14 +19,23 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = { content: "beta wrote this different line" }, - // Enable sync — Client 0 syncs first, then Client 1's create - // triggers a smart merge on the server { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyMergedContent } + { + type: "assert-consistent", + verify: (s) => + 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 f4a25896..bf144048 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,46 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed/deleted) - assert( - !state.files.has("A.md"), - `A.md should not exist. Files: ${files.join(", ")}` - ); - - // B.md should still exist unaffected - assert( - state.files.has("B.md"), - `B.md should exist (untouched). Files: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-b", - `B.md should have "content-b", got: "${state.files.get("B.md")}"` - ); - - // Clients must converge. If delete wins, A_renamed.md shouldn't exist. - // If rename wins, A_renamed.md should exist with content-a. - // Either way, both clients must agree. - if (state.files.has("A_renamed.md")) { - assert( - state.files.get("A_renamed.md") === "content-a", - `If A_renamed.md exists, it should have "content-a", got: "${state.files.get("A_renamed.md")}"` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const offlineDeleteRemoteRenameTest: TestDefinition = { - name: "Offline Delete + Concurrent Remote Rename", description: - "Client 0 goes offline and deletes A.md locally. Meanwhile Client 1 " + - "renames A.md to A_renamed.md and syncs. When Client 0 reconnects, " + - "the offline reconciliation discovers A.md is missing locally but the " + - "server has it renamed. The system must converge consistently.", + "Client 0 deletes A.md offline while client 1 renames it to A_renamed.md. " + + "After client 0 reconnects, both clients must converge.", clients: 2, steps: [ - // Setup { 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 }, @@ -48,11 +13,9 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline and deletes A.md { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, - // Client 1 renames A.md -> A_renamed.md { type: "rename", client: 1, @@ -61,12 +24,19 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients must converge - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => { + s.assertFileNotExists("A.md") + .assertContent("B.md", "content-b"); + s.ifFileExists("A_renamed.md", (s) => + s.assertContent("A_renamed.md", "content-a") + ); + } + } ] }; 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 d1d7dcf8..d86e3066 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,48 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyConsistentState(state: ClientState): void { - // After Client 0 deletes and Client 1 updates the same file, - // both clients must agree. The delete intent should win (user - // explicitly deleted the file) and both clients should converge - // to having no files OR the file re-created. - // - // The coalescing path is: local-update enqueued for Client 1's - // remote broadcast → local-delete arrives → coalesces. - // - // Key assertion: both clients must be consistent, regardless - // of which intent wins. - const files = Array.from(state.files.keys()); - // File should NOT exist (delete wins in current implementation) - assert( - state.files.size === 0, - `Expected 0 files after delete-wins resolution, got ${state.files.size}: ${files.join(", ")}` - ); -} - -/** - * Tests the coalescing path: `remote-update + local-delete → delete`. - * - * When Client 0 comes online after deleting A.md, it receives a - * remote-update broadcast for A.md from Client 1's edit. The - * coalescing must produce a `delete` action (not `remote-delete` - * with isDeleted=false) so the executor properly marks the doc as - * deleted-locally and sends DELETE to the server. - * - * Before the fix: the coalescing produced `remote-delete` with the - * remote-update version (isDeleted=false). The executor treated this - * as a tracked doc update, downloaded the remote content, and - * silently resurrected the file — overriding the user's delete. - */ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { - name: "Offline Delete vs Remote Update", description: - "Client 0 deletes A.md while Client 1 updates A.md. Tests the " + - "coalescing of remote-update + local-delete and whether both " + - "clients converge to a consistent state.", + "Client 0 deletes A.md offline while client 1 updates it. Both clients must converge.", clients: 2, steps: [ - // Setup: both clients share A.md { type: "create", client: 0, @@ -54,17 +16,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original content") }, - // Client 0 goes offline and deletes A.md { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, - // Client 1 updates A.md while Client 0 is offline { type: "update", client: 1, @@ -73,12 +31,13 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online — receives remote-update for A.md - // but has already deleted it locally { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConsistentState } + { + type: "assert-consistent", + verify: (s) => 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 16bcdfce..fc4383e4 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,56 +1,21 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyEditPreservedAtNewPath(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed to B.md) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${files.join(", ")}` - ); - - // B.md should exist with Client 0's edit merged in - assert( - state.files.has("B.md"), - `Expected B.md to exist. Files: ${files.join(", ")}` - ); - - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("edited by client 0"), - `Expected B.md to contain Client 0's edit "edited by client 0", got: "${content}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineEditRemoteRenameTest: TestDefinition = { - name: "Offline Edit + Remote Rename", description: - "Client 0 goes offline and edits A.md. Meanwhile Client 1 renames " + - "A.md to B.md. When Client 0 reconnects, its edit should be applied " + - "to B.md (the renamed path). The edit must not be lost and A.md must " + - "not exist.", + "Client 0 edits A.md offline while client 1 renames A.md to B.md. " + + "After client 0 reconnects, the edit must appear in B.md and A.md must not exist.", clients: 2, steps: [ - // Setup: create and sync { 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-content", - client: 1, - path: "A.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original") }, - // Client 0 goes offline and edits { type: "disable-sync", client: 0 }, { type: "update", @@ -59,7 +24,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = { content: "edited by client 0" }, - // Client 1 renames A.md -> B.md while Client 0 is offline { type: "rename", client: 1, @@ -68,13 +32,17 @@ export const offlineEditRemoteRenameTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects — edit must be preserved at new path { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyEditPreservedAtNewPath } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileCount(1) + .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 cf8b36e8..77d50099 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,49 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: File moved AND edited to have the same hash as another file. - * - * reconcileWithDisk detects moves by matching content hashes. But if a - * file is moved AND edited such that its new content matches a different - * missing file's hash, the move detection assigns it to the WRONG document. - * - * Scenario: - * 1. Two files exist: A.md ("content A") and B.md ("content B") - * 2. Client goes offline - * 3. A.md is deleted, B.md is renamed to C.md and edited to "content A" - * 4. On reconnect, reconcileWithDisk sees: - * - Missing: A.md (hash="content A"), B.md (hash="content B") - * - New: C.md (hash="content A") - * - C.md's hash matches A.md's hash → wrong move detection! - * - B.md is treated as deleted instead of renamed - * - * The system should still converge correctly despite the false match. - */ -function verifyFinalState(state: ClientState): void { - assert(!state.files.has("A.md"), "A.md should not exist"); - assert(!state.files.has("B.md"), "B.md should not exist"); - assert(state.files.has("C.md"), "C.md should exist"); - const content = state.files.get("C.md") ?? ""; - assert( - content === "content A", - `Expected C.md to contain "content A", got: "${content}"` - ); - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineEditThenMoveSameContentTest: TestDefinition = { - name: "Offline Move + Edit Creates False Hash Match", description: - "A file is renamed and edited to have the same content as a deleted " + - "file. Move detection may match against the wrong document. The " + - "system should still converge.", + "A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.", clients: 2, steps: [ - // Setup: create two files with different content { type: "create", client: 0, @@ -61,16 +22,12 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Delete A.md { type: "delete", client: 0, path: "A.md" }, - // Rename B.md → C.md { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, - // Edit C.md to have the same content as the now-deleted A.md { type: "update", client: 0, @@ -78,11 +35,18 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { content: "content A" }, - // Reconnect { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .assertContent("C.md", "content A") + .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 ca6a3c91..68453a0e 100644 --- a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -1,57 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // file1.md was deleted -- must not exist - assert( - !state.files.has("file1.md"), - `file1.md should have been deleted but exists. Files: ${files.join(", ")}` - ); - - // file2.md was renamed to moved.md - assert( - !state.files.has("file2.md"), - `file2.md should have been renamed but still exists. Files: ${files.join(", ")}` - ); - assert( - state.files.has("moved.md"), - `moved.md should exist after rename. Files: ${files.join(", ")}` - ); - const movedContent = state.files.get("moved.md") ?? ""; - assert( - movedContent === "content-2", - `moved.md should have original content "content-2", got: "${movedContent}"` - ); - - // file3.md was updated - assert( - state.files.has("file3.md"), - `file3.md should exist. Files: ${files.join(", ")}` - ); - const file3Content = state.files.get("file3.md") ?? ""; - assert( - file3Content === "updated-content-3", - `file3.md should have "updated-content-3", got: "${file3Content}"` - ); - - // Exactly 2 files should remain - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMixedOperationsTest: TestDefinition = { - name: "Offline Mixed Operations (Delete + Rename + Edit)", description: "Client 0 creates 3 files, syncs to both clients. Client 0 goes offline, " + "deletes file 1, renames file 2 to a new name, and edits file 3. " + "When Client 0 reconnects, all three operations should propagate to Client 1.", clients: 2, steps: [ - // Setup: Client 0 creates 3 files and syncs { type: "create", client: 0, path: "file1.md", content: "content-1" }, { type: "create", client: 0, path: "file2.md", content: "content-2" }, { type: "create", client: 0, path: "file3.md", content: "content-3" }, @@ -60,30 +15,17 @@ export const offlineMixedOperationsTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Verify initial sync { - type: "assert-content", - client: 1, - path: "file1.md", - content: "content-1" - }, - { - type: "assert-content", - client: 1, - path: "file2.md", - content: "content-2" - }, - { - type: "assert-content", - client: 1, - path: "file3.md", - content: "content-3" + type: "assert-consistent", + verify: (s) => + s + .assertContent("file1.md", "content-1") + .assertContent("file2.md", "content-2") + .assertContent("file3.md", "content-3") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 performs three different offline operations { type: "delete", client: 0, path: "file1.md" }, { type: "rename", @@ -98,16 +40,19 @@ export const offlineMixedOperationsTest: TestDefinition = { content: "updated-content-3" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // All operations should have propagated - { type: "assert-not-exists", client: 1, path: "file1.md" }, - { type: "assert-not-exists", client: 1, path: "file2.md" }, - { type: "assert-exists", client: 1, path: "moved.md" }, - { type: "assert-exists", client: 1, path: "file3.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("file1.md") + .assertFileNotExists("file2.md") + .assertContent("moved.md", "content-2") + .assertContent("file3.md", "updated-content-3") + .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 2276d53a..d1522528 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,44 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Move + remote-delete coalescing uses stale source path. - * - * Found by: multi-client convergence agent (#10) - * - * When a local move and a remote-delete are coalesced for the same document: - * move(A→B) + remote-delete = delete(path: A) - * (sync-events.ts line 210-211) - * - * But the VFS has already moved the document from A to B (syncer.ts - * line 152 runs vfs.move() immediately on the local-move event). - * When the executor tries to find the document at path A (line 302 - * in syncer.ts), it returns undefined because D1 is now at path B. - * The delete is silently skipped. - * - * The system should recover via runFinalConsistencyCheck() or the next - * reconciliation cycle, which will detect that B.md exists on disk - * but the server says D1 is deleted. - * - * This test verifies that both clients converge — the file should end - * up deleted on both clients. - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMoveThenRemoteDeleteTest: TestDefinition = { - name: "Offline Move + Remote Delete Convergence", description: - "Client 0 renames A→B offline while Client 1 deletes A. " + - "The move+delete coalescing may use a stale path. " + - "Both clients should converge to having no files.", + "Client 0 renames A.md to B.md offline while client 1 deletes A.md. " + + "Both clients must converge to having no files.", clients: 2, steps: [ - // Setup: both have A.md { type: "create", client: 0, @@ -50,24 +17,23 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline, renames A→B { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - // Client 1 deletes A.md (broadcasts to server) { type: "delete", client: 1, path: "A.md" }, { type: "sync", client: 1 }, - // Client 0 reconnects — receives remote-delete while move is pending { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both should converge to no files - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .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 39aa7ba1..e242223a 100644 --- a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -1,72 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyOnlyLatestVersion(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "edit-5-final", - `Expected doc.md to have "edit-5-final" (latest edit), got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineMultipleEditsTest: TestDefinition = { - name: "Offline Multiple Edits Converge to Latest", description: "Client 0 creates a file and syncs. Client 0 goes offline, edits the file " + "5 times with different content. When Client 0 reconnects, both clients " + "must converge to the final version.", clients: 2, steps: [ - // Setup: create file and sync to both clients { 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-content", - client: 1, - path: "doc.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("doc.md", "original") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 makes 5 sequential edits while offline { type: "update", client: 0, path: "doc.md", content: "edit-1" }, { type: "update", client: 0, path: "doc.md", content: "edit-2" }, { type: "update", client: 0, path: "doc.md", content: "edit-3" }, { type: "update", client: 0, path: "doc.md", content: "edit-4" }, { type: "update", client: 0, path: "doc.md", content: "edit-5-final" }, - // Client 0 reconnects -- offline reconciliation should detect the - // changed hash and sync the current on-disk content (edit-5-final) { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients should have the final version { - type: "assert-content", - client: 0, - path: "doc.md", - content: "edit-5-final" - }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "edit-5-final" - }, - { type: "assert-consistent", verify: verifyOnlyLatestVersion } + type: "assert-consistent", + verify: (s) => + 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 4d2cb9d4..c446d459 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,60 +1,37 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyContent(state: ClientState): void { - // The file should be at B.md with the exact edited content - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content === "edited after rename", - `Expected B.md to be "edited after rename", got: "${content}"` - ); - - // A.md should not exist (renamed away) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Only B.md should exist - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineRenameAndEditTest: TestDefinition = { - name: "Offline Rename and Edit", description: "Client 0 creates A.md and syncs. Client 0 goes offline, renames A.md " + "to B.md, then edits B.md. When Client 0 reconnects, the rename and edit " + "should both propagate to Client 1.", clients: 2, steps: [ - // Setup: create and sync { 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-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original") + }, - // Client 0 goes offline, renames and edits { 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" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // A.md should be gone, B.md should have edited content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyContent } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("A.md") + .assertFileCount(1) + .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 4814118f..24f4ff2a 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,49 +1,22 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyResult(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // Y.md should exist — the renamed original document with - // Client 1's updated content merged in. - assert( - state.files.has("Y.md"), - `Expected Y.md to exist. Files: ${files.join(", ")}` - ); - const content = state.files.get("Y.md") ?? ""; - assert( - content.includes("updated-by-client-1"), - `Expected Y.md to contain "updated-by-client-1", got: "${content}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { - name: "Offline Rename + Remote Create at Old Path", description: "Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " + "(same document). When Client 0 reconnects, the rename and update " + "should merge. Y.md should exist with Client 1's content.", clients: 2, steps: [ - // Setup: create X.md and sync { 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-content", - client: 1, - path: "X.md", - content: "original" + type: "assert-consistent", + verify: (s) => s.assertContent("X.md", "original") }, - // Client 0 goes offline and renames { type: "disable-sync", client: 0 }, { type: "rename", @@ -52,7 +25,6 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { newPath: "Y.md" }, - // Client 1 updates the same document at X.md { type: "update", client: 1, @@ -61,12 +33,16 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 reconnects — must detect move AND merge with update { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both clients should converge: Y.md with Client 1's content - { type: "assert-consistent", verify: verifyResult } + { + type: "assert-consistent", + verify: (s) => + 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 9d4e6c44..47a88328 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,48 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyFinalState(state: ClientState): void { - const files = Array.from(state.files.keys()); - // Client 0 updated both files, then deleted B.md. - // Client 1 updated B.md while Client 0 was offline. - // - // After reconnect: - // - A.md should have Client 0's update - // - B.md: Client 0 deleted it (local intent), Client 1 updated it - // (remote update). The coalescing path determines which wins. - // Current behavior: delete wins (local-delete + remote-update - // coalesces differently depending on ordering). - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${files.join(", ")}` - ); - const aContent = state.files.get("A.md") ?? ""; - assert( - aContent === "A updated by client 0", - `Expected A.md to have Client 0's update, got: "${aContent}"` - ); - - // B.md should be gone (Client 0 deleted it) - assert( - !state.files.has("B.md"), - `Expected B.md to be deleted, got: ${files.join(", ")}` - ); -} - -/** - * Tests a complex offline scenario: Client 0 goes offline, updates - * two files, then deletes one of them. Meanwhile Client 1 updates - * the file that Client 0 will delete. When Client 0 comes online, - * the reconciliation must handle: - * 1. A.md: local update (straightforward) - * 2. B.md: deleted locally + updated remotely (conflict) - * - * This exercises the offline reconciliation ordering: - * updates are enqueued before deletes, and coalescing with - * remote updates received during reconnect. - */ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { - name: "Offline Update Both Files Then Delete One", description: "Client 0 goes offline, updates A.md and B.md, then deletes B.md. " + "Client 1 updates B.md while Client 0 is offline. When Client 0 " + @@ -50,7 +8,6 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { "consistently resolved (delete wins).", clients: 2, steps: [ - // Setup: create two files { type: "create", client: 0, @@ -68,22 +25,15 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "A original" - }, - { - type: "assert-content", - client: 1, - path: "B.md", - content: "B original" + type: "assert-consistent", + verify: (s) => + s + .assertContent("A.md", "A original") + .assertContent("B.md", "B original") }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 updates both files { type: "update", client: 0, @@ -97,10 +47,8 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { content: "B updated by client 0" }, - // Client 0 deletes B.md { type: "delete", client: 0, path: "B.md" }, - // Meanwhile Client 1 updates B.md { type: "update", client: 1, @@ -109,11 +57,16 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { }, { type: "sync", client: 1 }, - // Client 0 comes online { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertContent("A.md", "A updated by client 0") + .assertFileNotExists("B.md") + } ] }; 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 new file mode 100644 index 00000000..3449e676 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -0,0 +1,30 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { + description: + "Client 0 creates a binary file and renames it while offline, then reconnects and immediately deletes it. " + + "Both clients must converge to zero files.", + clients: 2, + steps: [ + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "disable-sync", client: 0 }, + + { type: "create", client: 0, path: "data.bin", content: "BINARY:offline-content" }, + { type: "rename", client: 0, oldPath: "data.bin", newPath: "moved.bin" }, + + { type: "enable-sync", client: 0 }, + { type: "delete", client: 0, path: "moved.bin" }, + + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + state.assertFileCount(0); + } + } + ] +}; 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 new file mode 100644 index 00000000..b575aa58 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -0,0 +1,34 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { + description: + "A file is deleted and recreated multiple times by alternating clients while both are online. " + + "Both clients must converge after each cycle.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "round 0" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 1" }, + { type: "barrier" }, + + { type: "delete", client: 0, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 1, path: "A.md", content: "round 2" }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + { type: "create", client: 0, path: "A.md", content: "round 3" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (s) => 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 new file mode 100644 index 00000000..16ed7236 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -0,0 +1,27 @@ +import type { TestDefinition } from "../test-definition"; + +export const onlineEditVsDeleteConvergenceTest: TestDefinition = { + description: + "Both clients are online. Client 0 edits a file while client 1 " + + "deletes it. The clients must converge to the same state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "edited by client 0" }, + { type: "delete", client: 1, path: "A.md" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (state) => { + state.ifFileExists("A.md", (s) => + s.assertContainsAny("A.md", "edited by client 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 6a22d200..eeb705de 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,48 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyMergedEdits(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - - // Both edits should be present in the merged result. - // Client 0 added "alpha addition" and Client 1 added "beta addition". - // The shared heading and footer should be preserved. - assert( - content.includes("# Title"), - `Expected "# Title" to be preserved, got: "${content}"` - ); - assert( - content.includes("alpha addition"), - `Expected Client 0's edit "alpha addition" to be present, got: "${content}"` - ); - assert( - content.includes("beta addition"), - `Expected Client 1's edit "beta addition" to be present, got: "${content}"` - ); - assert( - content.includes("footer"), - `Expected "footer" to be preserved, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const overlappingEditsSameSectionTest: TestDefinition = { - name: "Overlapping Edits in Same Section", description: - "Both clients edit the same document by adding content to different " + - "parts of the same section. Client 0 adds a line after the heading, " + - "Client 1 adds a line before the footer. The 3-way merge should " + - "preserve both edits without data loss.", + "Both clients go offline and edit different parts of the same document. " + + "After both reconnect, both edits must be preserved without data loss.", clients: 2, steps: [ - // Setup: create a multi-line document { type: "create", client: 0, @@ -54,11 +17,9 @@ export const overlappingEditsSameSectionTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both clients go offline and edit the same document { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0: add line after heading { type: "update", client: 0, @@ -66,7 +27,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = { content: "# Title\nalpha addition\n\nfooter" }, - // Client 1: add line before footer { type: "update", client: 1, @@ -74,13 +34,16 @@ export const overlappingEditsSameSectionTest: TestDefinition = { content: "# Title\n\nbeta addition\nfooter" }, - // Both reconnect { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both edits should be merged - { type: "assert-consistent", verify: verifyMergedEdits } + { + type: "assert-consistent", + verify: (s) => + 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 5cd558df..181f256c 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,79 +1,32 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Queue reset discards local events embedded in remote action types. - * - * In sync-event-queue.ts reset() (line 172-179): - * for (const [key, state] of this.documentStates.entries()) { - * if (state.action === "remote-update" || state.action === "remote-delete") { - * this.documentStates.delete(key); - * } - * } - * - * This removes all actions with type "remote-update" or "remote-delete". - * But coalescing can embed local events INTO remote actions: - * - * remote-update + local-update = remote-update (line 262-264) - * remote-delete + local-update = remote-delete (line 295-297) - * remote-delete + local-move = remote-delete (line 301-303) - * - * When the queue resets (WebSocket disconnect), these coalesced actions - * are removed — silently discarding the local-update/move intent. - * - * The local edit IS recovered on the next reconnect via - * scheduleSyncForOfflineChanges() (which scans the filesystem and - * detects hash mismatches). But there is a narrow window where the - * edit could be lost if metadata was partially updated. - * - * This test verifies that local edits survive a disconnect that happens - * while the edit is coalesced with a remote event. - */ -function verifyEditSurvived(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md")!; - // Both edits should survive — the filesystem scan on reconnect must recover the local edit - assert( - content.includes("from client 0") && content.includes("from client 1"), - `Expected merged content with both edits, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { - name: "Queue Reset Preserves Coalesced Local Edits", description: - "When a local-update is coalesced into a remote-update action " + - "and then the WebSocket disconnects, the queue reset removes " + - "the remote-update — potentially losing the local edit. " + - "The filesystem scan on reconnect should recover it.", + "Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " + + "After client 0 reconnects, both edits must be preserved.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 1 edits — this will broadcast a remote-update to client 0 { type: "update", client: 1, path: "doc.md", content: "from client 1" }, { type: "sync", client: 1 }, - // Client 0 edits (local-update) — may coalesce with the pending - // remote-update in the queue as: remote-update + local-update = remote-update { type: "update", client: 0, path: "doc.md", content: "from client 0" }, - // Immediately disconnect client 0 — queue.reset() removes remote events { type: "disable-sync", client: 0 }, - // Reconnect — scheduleSyncForOfflineChanges should detect the - // local edit via hash mismatch and re-queue it { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Both must converge with the local edit preserved - { type: "assert-consistent", verify: verifyEditSurvived } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"), + } ] }; 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 62fc7e41..cc011dc0 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,42 +1,9 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Rapid create-update-delete cycle tests coalescing correctness. - * - * When events arrive faster than the queue can process them, coalescing - * determines the final action. This tests the full cycle: - * - * create + update = create (content read at sync time) - * create + delete = noop - * - * So a create-update-delete sequence should coalesce to noop and never - * reach the server at all. - * - * But then a new create follows: - * noop + create = create - * - * The final file should be synced correctly. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("cycle.md"), "Expected cycle.md to exist"); - const content = state.files.get("cycle.md") ?? ""; - assert( - content === "final creation", - `Expected "final creation", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { - name: "Rapid Create-Update-Delete-Create Cycle", description: - "Client 0 rapidly creates, updates, deletes, then re-creates a file. " + - "The event coalescing should correctly reduce this to a single create " + - "of the final content. Client 1 should see only the final file.", + "Client 0 rapidly creates, updates, deletes, then re-creates a file while the server is paused. " + + "After the server resumes, client 1 must see only the final file.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -44,10 +11,8 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so all operations coalesce before being processed { type: "pause-server" }, - // Rapid cycle: create → update → delete { type: "create", client: 0, @@ -62,7 +27,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { }, { type: "delete", client: 0, path: "cycle.md" }, - // Re-create with final content { type: "create", client: 0, @@ -70,11 +34,13 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { content: "final creation" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => 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 new file mode 100644 index 00000000..042942b3 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -0,0 +1,44 @@ +import type { TestDefinition } from "../test-definition"; + +export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { + description: + "Client 0 rapidly edits multiple files while client 1 deletes some of them, all while both are online. " + + "Both clients must converge to a consistent state.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "content A" }, + { type: "create", client: 0, path: "B.md", content: "content B" }, + { type: "create", client: 0, path: "C.md", content: "content C" }, + { type: "create", client: 0, path: "D.md", content: "content D" }, + { type: "create", client: 0, path: "E.md", content: "content E" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "update", client: 0, path: "A.md", content: "A edit 1" }, + { type: "update", client: 0, path: "B.md", content: "B edit 1" }, + { type: "update", client: 0, path: "C.md", content: "C edit 1" }, + { type: "delete", client: 1, path: "A.md" }, + { type: "delete", client: 1, path: "C.md" }, + { type: "delete", client: 1, path: "E.md" }, + { type: "update", client: 0, path: "A.md", content: "A edit 2" }, + { type: "update", client: 0, path: "B.md", content: "B edit 2" }, + { type: "update", client: 0, path: "C.md", content: "C edit 2" }, + + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s) => { + for (const [path, content] of s.files) { + for (const clientFiles of s.clientFiles) { + if (clientFiles.has(path) && clientFiles.get(path) !== content) { + throw new Error( + `Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"` + ); + } + } + } + }, + }, + ], +}; diff --git a/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts b/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts deleted file mode 100644 index 6bfb3447..00000000 --- a/frontend/deterministic-tests/src/tests/rapid-sync-toggle.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const rapidSyncToggleTest: TestDefinition = { - name: "Rapid Sync Toggle", - description: - "Client 0 creates a file, then toggles sync off and on multiple times. " + - "The file should eventually sync to Client 1 without deadlocks or data loss.", - clients: 2, - steps: [ - { type: "enable-sync", client: 1 }, - - // Create a file while offline - { type: "create", client: 0, path: "stable.md", content: "must survive toggles" }, - - // Toggle sync on client 0 multiple times - { type: "enable-sync", client: 0 }, - { type: "disable-sync", client: 0 }, - { type: "enable-sync", client: 0 }, - { type: "disable-sync", client: 0 }, - - // Final enable — this one must succeed - { type: "enable-sync", client: 0 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "assert-exists", client: 0, path: "stable.md" }, - { type: "assert-exists", client: 1, path: "stable.md" }, - { - type: "assert-content", - client: 1, - path: "stable.md", - content: "must survive toggles" - }, - { type: "assert-consistent" } - ] -}; 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 e0d49bfd..bf0ed488 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,37 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}` - ); - assert( - state.files.has("doc.md"), - `Expected doc.md to exist` - ); - const content = state.files.get("doc.md") ?? ""; - - // After the merge and three rapid updates, "update 3" should be present. - // Earlier updates may be coalesced, but the final state must include the - // last update's content. - assert( - content.includes("update 3"), - `Expected final content to include "update 3", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const rapidUpdatesAfterMergeTest: TestDefinition = { - name: "Rapid Sequential Updates After Concurrent Merge", description: - "Both clients create the same file (triggering a merge). After merge " + - "completes, Client 0 rapidly sends three updates in succession. Each " + - "update must correctly use the content cache to compute diffs against " + - "the right parent version. Tests that the cache stores server content " + - "(not local content) after MergingUpdate.", + "Both clients create the same file offline, triggering a merge on sync. " + + "Client 0 then rapidly sends three updates. Both clients must converge to the final update.", clients: 2, steps: [ - // Both create at same path (triggers merge) { type: "create", client: 0, path: "doc.md", content: "from client 0" }, { type: "create", client: 1, path: "doc.md", content: "from client 1" }, @@ -40,7 +14,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // After merge, Client 0 sends rapid sequential updates { type: "update", client: 0, @@ -65,10 +38,11 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { }, { type: "sync", client: 0 }, - // Wait for propagation { type: "barrier" }, - // Both clients must converge with update 3 - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => 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 245db72e..d8d0cf21 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,65 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: recentlyDeletedIds must be cleared on reconnect. - * - * Scenario: - * 1. Client 0 creates and syncs doc.md - * 2. Client 0 deletes doc.md (adds to recentlyDeletedIds) - * 3. Client 0 goes offline - * 4. Client 1 creates a NEW doc.md (different documentId) - * 5. Client 0 comes online - * 6. Client 0 should receive the new doc.md from client 1 - * (recentlyDeletedIds should have been cleared on reconnect so - * the new documentId is not blocked) - */ -function verifyFileExists(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content === "new content from client 1", - `Expected "new content from client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { - name: "Recently Deleted IDs Cleared On Reconnect", description: "After a client deletes a document and reconnects, it should " + "accept new documents from other clients even if they happen to " + "arrive at the same path as the deleted document.", clients: 2, steps: [ - // Setup: both online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 creates and syncs a file { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "sync" }, { type: "barrier" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "doc.md" }, { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 1 creates a new file at the same path { type: "create", client: 1, path: "doc.md", content: "new content from client 1" }, { type: "sync", client: 1 }, - // Client 0 comes back online - should receive the new file { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyFileExists }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "new content from client 1"), + }, ], }; 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 3d89e693..27787e4f 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,40 +1,23 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyAllDeleted(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 0, - `Expected no files (document was deleted after rename chain), got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameChainThenDeleteTest: TestDefinition = { - name: "Rename Chain Then Delete (Offline Catchup)", description: - "Client 0 creates X.md and syncs. Client 1 goes offline. Client 0 " + - "renames X.md -> Y.md -> Z.md, then deletes Z.md. Client 1 reconnects " + - "with X.md still on disk. The offline reconciliation must detect that " + - "the document was deleted (despite the rename chain) and remove X.md.", + "Client 0 renames X.md to Y.md to Z.md, then deletes Z.md while client 1 is offline. " + + "After client 1 reconnects, both clients must have no files.", clients: 2, steps: [ - // Setup: create and sync { 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-content", - client: 1, - path: "X.md", - content: "chain-content" + type: "assert-consistent", + verify: (s) => s.assertContent("X.md", "chain-content"), }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0: rename chain X -> Y -> Z, then delete Z { type: "rename", client: 0, @@ -52,12 +35,10 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "delete", client: 0, path: "Z.md" }, { type: "sync", client: 0 }, - // Client 1 reconnects — should detect X.md's document is deleted { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both clients must agree: no files - { type: "assert-consistent", verify: verifyAllDeleted } + { type: "assert-consistent", verify: (s) => 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 75b33535..8cc3bde3 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -1,7 +1,6 @@ import type { TestDefinition } from "../test-definition"; export const renameChainTest: TestDefinition = { - name: "Rename Chain", description: "Client 0 (offline) creates A.md, renames to B.md, then renames to C.md. " + "When sync is enabled, only C.md should exist. Client 1 should receive C.md " + @@ -10,27 +9,20 @@ export const renameChainTest: TestDefinition = { steps: [ { type: "enable-sync", client: 1 }, - // Client 0 creates and renames while offline { 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" }, - // Enable sync — reconciliation discovers C.md as a new file { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Only C.md should exist on both clients - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 0, path: "C.md" }, - { type: "assert-content", client: 0, path: "C.md", content: "important content" }, - - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-exists", client: 1, path: "C.md" }, - { type: "assert-content", client: 1, path: "C.md", content: "important content" }, - - { type: "assert-consistent" } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md") + .assertFileNotExists("B.md") + .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 6b1c9069..233b5c86 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -1,60 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyCircularRotation(state: ClientState): void { - // Temp file must not survive the rotation - assert( - !state.files.has("temp-a.md"), - `temp-a.md should not exist after rotation, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Exactly 3 files should exist - assert( - state.files.size === 3, - `Expected exactly 3 files after rotation, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("C.md"), - `Expected C.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - // After circular rename A->B, B->C, C->A: - // A.md should have C's original content - // B.md should have A's original content - // C.md should have B's original content - assert( - state.files.get("A.md") === "content-c", - `Expected A.md to have "content-c" after rotation, got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a" after rotation, got: "${state.files.get("B.md")}"` - ); - assert( - state.files.get("C.md") === "content-b", - `Expected C.md to have "content-b" after rotation, got: "${state.files.get("C.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameCircularTest: TestDefinition = { - name: "Circular Rename Chain (3-Way Swap)", description: - "Client 0 has A.md, B.md, C.md synced. Goes offline and performs a " + - "circular rename: A->B, B->C, C->A. This requires temp files to avoid " + - "overwriting. When Client 0 reconnects, all three files should have " + - "rotated content on both clients.", + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.", clients: 2, steps: [ - // Setup: create three files and sync to both clients { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "C.md", content: "content-c" }, @@ -62,32 +12,32 @@ export const renameCircularTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "content-a" }, - { type: "assert-content", client: 1, path: "B.md", content: "content-b" }, - { type: "assert-content", client: 1, path: "C.md", content: "content-c" }, + { + type: "assert-consistent", + verify: (s) => + s.assertContent("A.md", "content-a") + .assertContent("B.md", "content-b") + .assertContent("C.md", "content-c"), + }, - // Client 0 goes offline and performs the 3-way circular rename - // To avoid overwriting, we use temp files: - // 1. A.md -> temp-a.md (save A's content) - // 2. C.md -> A.md (A now has C's content) - // 3. B.md -> C.md (C now has B's content) - // 4. temp-a.md -> B.md (B now has A's content) { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" }, { type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Temp file should not exist on either client - { type: "assert-not-exists", client: 0, path: "temp-a.md" }, - { type: "assert-not-exists", client: 1, path: "temp-a.md" }, - - // All three files should exist with rotated content - { type: "assert-consistent", verify: verifyCircularRotation } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("temp-a.md") + .assertFileCount(3) + .assertContent("A.md", "content-c") + .assertContent("B.md", "content-a") + .assertContent("C.md", "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 2b1938a0..c29b1dc5 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -1,32 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - const files = Array.from(state.files.keys()); - - // B.md should exist (client 1 renamed A.md to B.md, and client 0 - // created B.md with same content — the server merges them) - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "hi", - `Expected B.md to have "hi", got: "${state.files.get("B.md")}"` - ); - - // A.md should not exist (it was renamed to B.md) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename, got: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameCreateConflictTest: TestDefinition = { - name: "Rename-Create Conflict", description: - "Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " + - "The system must resolve the conflict deterministically.", + "Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -34,8 +10,10 @@ export const renameCreateConflictTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "hi" }, { type: "sync", client: 0 }, { type: "sync", client: 1 }, - { type: "assert-exists", client: 1, path: "A.md" }, - { type: "assert-content", client: 1, path: "A.md", content: "hi" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "hi"), + }, { type: "disable-sync", client: 0 }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 1 }, @@ -43,6 +21,10 @@ export const renameCreateConflictTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConflictResolution } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContent("B.md", "hi"), + } ] }; 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 9d9b9b1d..d38a0392 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,56 +1,17 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Renaming a file while its create request is in-flight orphans the document. - * - * Scenario: - * 1. Client 0 creates `doc.md` (pending create, HTTP request in-flight) - * 2. Server is paused so the create stalls - * 3. Client 0 renames `doc.md` → `renamed.md` before the response - * 4. VFS.move() updates the pending document's path to `renamed.md` - * 5. Server resumes, create response confirms document at `doc.md` - * 6. The sync executor may fail to reconcile because the VFS no longer - * has a document at `doc.md` — it was moved to `renamed.md` - * - * Expected: the file should end up at `renamed.md` on both clients. - * The server document at `doc.md` should be renamed to `renamed.md` - * via a follow-up sync operation. - */ -function verifyFileAtRenamedPath(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("renamed.md"), - `Expected renamed.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("renamed.md") ?? ""; - assert( - content === "original-content", - `Expected "original-content", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renamePendingCreateBeforeResponseTest: TestDefinition = { - name: "Rename Pending Create Before Server Response", description: - "When a file is renamed while its create request is in-flight, " + - "the document must not become orphaned. Both clients should " + - "converge with the file at the renamed path.", + "Client 0 creates a file while the server is paused, then renames it before the create completes. After the server resumes, both clients should converge with the file at the renamed path.", clients: 2, steps: [ - // Both clients online { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Pause server so the create stalls { type: "pause-server" }, - // Client 0 creates doc.md (request stalls at server) { type: "create", client: 0, @@ -58,9 +19,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { content: "original-content" }, - // Wait for the create to enter the executor - - // Client 0 renames the file WHILE create is in-flight { type: "rename", client: 0, @@ -68,15 +26,16 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { newPath: "renamed.md" }, - // Resume server — create response arrives for "doc.md" { type: "resume-server" }, - // Give time for create response + follow-up rename sync { type: "sync" }, { type: "sync" }, { type: "barrier" }, - // File should be at renamed.md on both clients - { type: "assert-consistent", verify: verifyFileAtRenamedPath } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("renamed.md", "original-content"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts index 468d2d29..bdf043f4 100644 --- a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -1,61 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyRoundtrip(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - files.includes("A.md"), - `Expected A.md to exist after round-trip rename, got: ${files.join(", ")}` - ); - assert( - !files.includes("B.md"), - `B.md should not exist after round-trip rename, got: ${files.join(", ")}` - ); - assert( - state.files.get("A.md") === "original", - `Expected A.md to have "original" content, got: "${state.files.get("A.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameRoundtripTest: TestDefinition = { - name: "Rename Round-Trip (A->B->A)", description: - "Client 0 creates A.md and syncs. Then renames A.md to B.md and syncs. " + - "Then renames B.md back to A.md and syncs. Both clients should end with " + - "A.md at the original path with the original content. B.md should not exist. " + - "Tests that the system correctly handles a rename that returns to the " + - "original path, especially regarding document identity tracking.", + "Client 0 creates A.md, renames it to B.md, then renames it back to A.md. After each step both clients sync. Both should end with only A.md at the original path.", clients: 2, steps: [ - // Setup: create and sync { 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-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original"), + }, - // First rename: A.md -> B.md { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Verify intermediate state: only B.md exists - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-content", client: 0, path: "B.md", content: "original" }, - { type: "assert-content", client: 1, path: "B.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContent("B.md", "original"), + }, - // Second rename: B.md -> A.md (back to original path) { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "sync" }, { type: "barrier" }, - // Final state: back to A.md with original content - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyRoundtrip } + { + type: "assert-consistent", + verify: (s) => + 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 feb635a5..1cd9c93c 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -1,28 +1,6 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifySwap(state: ClientState): void { - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - // After the swap, A.md should have B's original content and vice versa - assert( - state.files.get("A.md") === "content-b", - `Expected A.md to have "content-b" after swap, got: "${state.files.get("A.md")}"` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a" after swap, got: "${state.files.get("B.md")}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameSwapTest: TestDefinition = { - name: "Offline Swap via Temp File", 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. " + @@ -30,32 +8,34 @@ export const renameSwapTest: TestDefinition = { "The temp file should not exist on either client.", clients: 2, steps: [ - // Setup: create both files and sync to both clients { 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: "assert-content", client: 1, path: "A.md", content: "content-a" }, - { type: "assert-content", client: 1, path: "B.md", content: "content-b" }, + { + type: "assert-consistent", + verify: (s) => + s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"), + }, - // Client 0 goes offline and performs the swap { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, - // Client 0 reconnects { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // temp.md should not exist on either client - { type: "assert-not-exists", client: 0, path: "temp.md" }, - { type: "assert-not-exists", client: 1, path: "temp.md" }, - - // Both clients should have the swapped content - { type: "assert-consistent", verify: verifySwap } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("temp.md") + .assertContent("A.md", "content-b") + .assertContent("B.md", "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 index 0cdd8718..b1d09c7f 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts @@ -1,32 +1,11 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - // A.md should not exist (it was renamed) - assert(!state.files.has("A.md"), "A.md should not exist after rename"); - // B.md should exist with the alpha content (from the renamed A.md) - assert(state.files.has("B.md"), "B.md should exist"); - assert( - state.files.get("B.md") === "alpha", - `B.md should have "alpha" content, got: "${state.files.get("B.md")}"` - ); - // The original B.md content ("beta") should be overwritten — only the - // renamed content should survive. Verify no other files contain "beta". - const allContent = Array.from(state.files.values()).join("\n"); - assert( - !allContent.includes("beta"), - `Expected "beta" to be gone after overwrite, but found it in: ${JSON.stringify(Object.fromEntries(state.files))}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToExistingPathTest: TestDefinition = { - name: "Rename to Existing Path", 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: [ - // Setup: create two files and sync { type: "create", client: 0, path: "A.md", content: "alpha" }, { type: "create", client: 0, path: "B.md", content: "beta" }, { type: "enable-sync", client: 0 }, @@ -34,14 +13,14 @@ export const renameToExistingPathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 renames A.md to B.md (overwrites B.md) { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync" }, { type: "barrier" }, - // Both should converge - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + 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 4db2faea..543599bb 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,50 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Rename to the path of a document whose delete hasn't been - * confirmed on the server yet. - * - * The VFS move() method (vfs.ts line 494-497) silently removes any existing - * document at the target path from the pathIndex. If the target path holds - * a tracked document that is about to be deleted (but the delete hasn't - * been sent to the server yet), the move will remove it from pathIndex, - * potentially causing a deleted-locally document to lose its path reference. - * - * Scenario: - * 1. Both clients have A.md and B.md - * 2. Client 0 goes offline, deletes A.md, renames B.md → A.md - * 3. On reconnect: - * - The delete of A.md is queued - * - The rename of B.md → A.md needs VFS.move(B.md, A.md) - * - But A.md is still in pathIndex (tracked, not yet deleted) - * - VFS.move removes A.md from pathIndex before the delete is confirmed - * - * Expected: A.md's documentId is deleted on server, B.md's document - * is renamed to A.md, both clients converge. - */ -function verifyFinalState(state: ClientState): void { - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert(state.files.has("A.md"), "Expected A.md to exist"); - const content = state.files.get("A.md") ?? ""; - assert( - content === "content B", - `Expected "content B", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { - name: "Rename to Path of Unconfirmed Delete", description: - "Client deletes A.md and renames B.md to A.md while offline. " + - "On reconnect, the VFS must handle the path conflict between " + - "the tracked A.md (pending delete) and the rename destination.", + "Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.", clients: 2, steps: [ - // Setup: both clients have A.md and B.md { type: "create", client: 0, @@ -62,21 +22,22 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Delete A.md, then rename B.md → A.md { type: "delete", client: 0, path: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - // Reconnect { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Should converge: A.md exists with B's content, B.md gone - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileNotExists("B.md") + .assertContent("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 e4f95852..a17f52d4 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,80 +1,30 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: syncLocallyUpdatedFile does not handle pending doc at target path. - * - * In syncer.ts syncLocallyUpdatedFile (lines 146-195), the if/else chain: - * if (existingAtNew === undefined || existingAtNew.state === "deleted-locally") - * else if (existingAtNew.state === "tracked") - * - * There is NO branch for existingAtNew.state === "pending". When a tracked - * doc is renamed to a path occupied by a pending create: - * - * 1. No branch matches → vfsMoveSucceeded stays false - * 2. Falls back to local-update at oldPath - * 3. File is on disk at newPath (user renamed it) - * 4. Executor reads from oldPath → FileNotFoundError - * 5. Operation is silently dropped - * 6. Tracked doc at oldPath becomes orphaned (VFS entry, no file) - * 7. On next reconciliation, recovers via filesystem scan - * - * This test verifies that the rename eventually converges, even though - * the initial sync attempt fails. The pending doc at the target path - * should be handled properly. - */ -function verifyFinalState(state: ClientState): void { - // After convergence, A.md should exist with B's content (B was - // renamed to A, overwriting the pending A). B.md should not exist. - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - !state.files.has("B.md"), - `Expected B.md to not exist (was renamed to A.md), got: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("A.md") ?? ""; - assert( - content.includes("tracked B content"), - `Expected A.md to have B's content, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToPendingPathFallbackTest: TestDefinition = { - name: "Rename Tracked File to Path With Pending Create", description: - "When a tracked document is renamed to a path occupied by a " + - "pending create, the VFS move is skipped (no branch for pending " + - "state). The fallback update fails with FileNotFoundError. " + - "Reconciliation should eventually recover.", + "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: [ - // Setup: B.md tracked and synced on both clients { 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" }, - // Client 0 goes offline { type: "disable-sync", client: 0 }, - // Client 0 creates A.md (pending, never synced) { type: "create", client: 0, path: "A.md", content: "pending A content" }, - // Client 0 renames B.md → A.md (overwrites the pending A) - // This triggers the missing-branch bug { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - // Re-enable sync { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - // Verify B.md is gone and A.md exists with B's content - { type: "assert-not-exists", client: 0, path: "B.md" }, - { type: "assert-not-exists", client: 1, path: "B.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + 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 index 4cb5588c..754c0c18 100644 --- 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 @@ -1,41 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()).sort(); - - // A.md should not exist (it was renamed away by Client 1) - assert( - !state.files.has("A.md"), - `A.md should not exist after rename. Files: ${files.join(", ")}` - ); - - // B.md should exist — Client 1 renamed A.md to B.md, reclaiming the - // path that Client 0 had just deleted. Content should be "content-a". - assert( - state.files.has("B.md"), - `Expected B.md to exist (renamed from A.md). Files: ${files.join(", ")}` - ); - assert( - state.files.get("B.md") === "content-a", - `Expected B.md to have "content-a", got: "${state.files.get("B.md")}"` - ); - - assert( - state.files.size === 1, - `Expected exactly 1 file, got ${state.files.size}: ${files.join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameToRecentlyDeletedPathTest: TestDefinition = { - name: "Rename to a Path That Was Recently Deleted", description: - "Client 0 deletes B.md and syncs. Client 1 (offline) renames A.md " + - "to B.md — claiming the path that was just vacated. When Client 1 " + - "reconnects, the rename should succeed at B.md without collision.", + "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: [ - // Setup: create both files { 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 }, @@ -43,14 +12,11 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes B.md { type: "delete", client: 0, path: "B.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) renames A.md to B.md { type: "rename", client: 1, @@ -58,12 +24,17 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { newPath: "B.md" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both clients should converge: only B.md with content-a - { type: "assert-consistent", verify: verifyConvergence } + { + 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 0fcc7735..099009fb 100644 --- a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -1,58 +1,35 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConvergence(state: ClientState): void { - const files = Array.from(state.files.keys()); - // A.md should not exist (it was renamed to B.md by client 0) - assert( - !files.includes("A.md"), - `Expected A.md to not exist after rename, but found files: ${files.join(", ")}` - ); - // B.md should exist (the rename target) - assert( - files.includes("B.md"), - `Expected B.md to exist after rename, but found files: ${files.join(", ")}` - ); - // B.md should contain client 1's update (merged with the rename) - const content = state.files.get("B.md") ?? ""; - assert( - content.includes("updated"), - `Expected B.md to contain "updated" from client 1's edit, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const renameUpdateConflictTest: TestDefinition = { - name: "Rename vs Update Conflict", description: - "Client 0 renames A.md to B.md while Client 1 (offline) updates A.md. " + - "When Client 1 reconnects, the update should be applied to B.md (the " + - "renamed file) via 3-way merge. Both clients should converge.", + "Client 0 renames A.md to B.md while client 1 updates A.md offline. After client 1 reconnects, both should converge with the update at B.md.", clients: 2, steps: [ - // Setup: create A.md and sync to both clients { 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-content", client: 1, path: "A.md", content: "original" }, + { + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original"), + }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 renames A.md to B.md and syncs { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 0 }, - // Client 1 (offline) updates A.md { type: "update", client: 1, path: "A.md", content: "updated by client 1" }, - // Client 1 reconnects — must reconcile rename with update { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Verify convergence - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => + s.assertFileNotExists("A.md").assertContains("B.md", "updated"), + } ] }; 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 a17546ed..e7b001e2 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,43 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: recentlyDeletedIds cleared on sync reset can allow document resurrection. - * - * Found by: multi-client convergence agent (#10) - * - * When the VFS is reset (syncer.ts line 225-229, on WebSocket disconnect), - * the recentlyDeletedIds set is NOT cleared by syncer.reset() (which only - * calls queue.reset()). The VFS.reset() DOES clear it (line 646), but - * syncer.reset() doesn't call vfs.reset(). - * - * However, there's a related edge case: if sync is toggled off and on - * (which calls pause/resume), the recentlyDeletedIds persists correctly. - * But if the client deletes a document and then loses connection, the - * lastSeenUpdateId watermark may not have advanced past the delete. - * On reconnect, the server replays the delete broadcast, and the client - * should handle it correctly. - * - * This test verifies that after Client 0 deletes a file and Client 1 - * toggles sync off and on, the delete is properly applied and no - * resurrection occurs. - */ -function verifyNoFiles(state: ClientState): void { - assert( - state.files.size === 0, - `Expected 0 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); -} +import type { TestDefinition } from "../test-definition"; export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { - name: "Sync Reset Does Not Resurrect Deleted Documents", description: "Client 0 deletes a file. Client 1 toggles sync off and on " + "(simulating reconnect). The deleted file should NOT reappear " + "on Client 1 after the sync reset.", clients: 2, steps: [ - // Setup { type: "create", client: 0, @@ -49,26 +18,25 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 deletes the file { type: "delete", client: 0, path: "ghost.md" }, { type: "sync", client: 0 }, - // Wait for broadcast to propagate { type: "sync" }, { type: "barrier" }, - // Client 1 should NOT have the file - { type: "assert-not-exists", client: 1, path: "ghost.md" }, + { + type: "assert-consistent", + verify: (s) => s.assertFileNotExists("ghost.md"), + }, - // Client 1 toggles sync (simulating disconnect/reconnect) { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // File should STILL be gone — no resurrection - { type: "assert-not-exists", client: 0, path: "ghost.md" }, - { type: "assert-not-exists", client: 1, path: "ghost.md" }, - { type: "assert-consistent", verify: verifyNoFiles } + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(0), + } ] }; 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 49581c46..968166a9 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,66 +1,32 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFilesPreserved(state: ClientState): void { - assert( - state.files.size === 2, - `Expected 2 files, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("A.md"), - `Expected A.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - - const contentA = state.files.get("A.md") ?? ""; - const contentB = state.files.get("B.md") ?? ""; - assert( - contentA === "identical content here", - `A.md has wrong content: "${contentA}"` - ); - assert( - contentB === "identical content here", - `B.md has wrong content: "${contentB}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const sequentialCreateDuplicateContentTest: TestDefinition = { - name: "Sequential Creates With Identical Content Preserved", description: - "Client 0 creates A.md and syncs it. Then Client 0 creates B.md with " + - "the exact same content as A.md and syncs again. Both files must be " + - "preserved as separate documents — the duplicate content detection " + - "must not collapse them into one file or delete B.md.", + "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: [ - // Create A.md and sync it fully { 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" }, - // Verify A.md arrived on client 1 { - type: "assert-content", - client: 1, - path: "A.md", - content: "identical content here" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "identical content here"), }, - // Now create B.md with identical content on client 0 { type: "create", client: 0, path: "B.md", content: "identical content here" }, { type: "sync" }, { type: "barrier" }, - // Both files must exist on both clients with correct content. - // This catches bugs where duplicate detection (content hash matching - // during offline reconciliation) accidentally treats B.md as a - // "move" of A.md, or where the server merges B.md into A.md's - // document because of identical content at a different path. - { type: "assert-consistent", verify: verifyBothFilesPreserved } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(2) + .assertContent("A.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 46c7107e..fea4adad 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,36 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyBothFiles(state: ClientState): void { - assert( - state.files.has("alpha.md"), - `Expected alpha.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - assert( - state.files.has("beta.md"), - `Expected beta.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const alphaContent = state.files.get("alpha.md") ?? ""; - const betaContent = state.files.get("beta.md") ?? ""; - assert( - alphaContent.includes("from client 0"), - `Expected alpha.md to contain "from client 0", got: "${alphaContent}"` - ); - assert( - betaContent.includes("from client 1"), - `Expected beta.md to contain "from client 1", got: "${betaContent}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseBothClientsCreateTest: TestDefinition = { - name: "Server Pause While Both Clients Create", description: - "Both clients are synced. Client 0 creates alpha.md. The server is immediately " + - "paused (SIGSTOP), stalling in-flight requests and WebSocket broadcasts. " + - "While the server is paused, Client 1 creates beta.md (its request will also stall). " + - "After the server resumes, both files should propagate to both clients. " + - "This tests that the retry logic on both clients correctly recovers stalled " + - "HTTP creates and that WebSocket reconnection delivers the missed broadcasts.", + "Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -38,8 +10,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Client 0 creates a file, then immediately pause the server - // so the create response (or broadcast to client 1) may be stalled { type: "create", client: 0, @@ -48,8 +18,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { }, { type: "pause-server" }, - // While server is paused, client 1 creates a different file. - // This HTTP request will stall until the server is resumed. { type: "create", client: 1, @@ -57,18 +25,17 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { content: "from client 1" }, - // Resume the server — both stalled requests should complete { type: "resume-server" }, - // Let both clients finish all pending sync work { type: "sync" }, { type: "barrier" }, - // Both files must exist on both clients - { type: "assert-exists", client: 0, path: "alpha.md" }, - { type: "assert-exists", client: 0, path: "beta.md" }, - { type: "assert-exists", client: 1, path: "alpha.md" }, - { type: "assert-exists", client: 1, path: "beta.md" }, - { type: "assert-consistent", verify: verifyBothFiles } + { + type: "assert-consistent", + verify: (s) => + 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 51a80898..394a531a 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,57 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * EDGE CASE: Both clients edit the same file while server is paused. - * - * When the server is paused (SIGSTOP), both clients' HTTP requests stall. - * When the server resumes, both updates arrive nearly simultaneously. - * The server processes them sequentially (SQLite), so one will be a - * FastForwardUpdate and the other will trigger a 3-way merge. - * - * This test verifies: - * 1. Both edits are preserved in the merged result - * 2. Both clients converge to the same content - * 3. The content cache on both clients is correct after the merge - * (subsequent edits use the right diff base) - * - * After the initial merge converges, Client 0 makes another edit to - * verify the content cache is correct — if the cache has wrong content, - * the diff will be computed incorrectly and the update will fail. - */ -function verifyBothConcurrentEdits(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("shared.md"), "Expected shared.md to exist"); - const content = state.files.get("shared.md") ?? ""; - assert( - content.includes("edited by client 0"), - `Expected content to include client 0's edit, got: "${content}"` - ); - assert( - content.includes("edited by client 1"), - `Expected content to include client 1's edit, got: "${content}"` - ); -} - -function verifyPostMergeEdit(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("shared.md"), "Expected shared.md to exist"); - const content = state.files.get("shared.md") ?? ""; - assert( - content.includes("post-merge edit from client 0"), - `Expected content to include post-merge edit, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseBothEditSameFileTest: TestDefinition = { - name: "Server Pause — Both Clients Edit Same File + Post-Merge Edit", description: - "Both clients edit the same file while the server is paused. " + - "After resume and convergence, Client 0 makes another edit to " + - "verify the content cache is consistent (correct diff base).", + "Both clients edit different sections of the same file while the server is paused. After resuming and converging, client 0 makes another edit to verify further updates still work correctly.", clients: 2, steps: [ - // Setup { type: "create", client: 0, @@ -63,10 +16,8 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server { type: "pause-server" }, - // Both clients edit different sections { type: "update", client: 0, @@ -82,15 +33,18 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { "line 1: original\nline 2: original\nline 3: edited by client 1" }, - // Resume — both updates hit server nearly simultaneously { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Verify both concurrent edits are preserved in the merge - { type: "assert-consistent", verify: verifyBothConcurrentEdits }, + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertContains("shared.md", "edited by client 0", "edited by client 1"), + }, - // Now Client 0 makes another edit (verifies content cache is correct) { type: "update", client: 0, @@ -100,6 +54,10 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyPostMergeEdit } + { + type: "assert-consistent", + verify: (s) => + 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 new file mode 100644 index 00000000..920259e1 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -0,0 +1,32 @@ +import type { TestDefinition } from "../test-definition"; + +export const serverPauseDeleteRecreateTest: TestDefinition = { + description: + "Client 1 deletes a file and syncs. The server is paused, then client 0 creates at the same path. After the server resumes, both clients should have the recreated file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "original" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + + { type: "delete", client: 1, path: "A.md" }, + { type: "barrier" }, + + { type: "pause-server" }, + + { type: "create", client: 0, path: "A.md", content: "recreated during contention" }, + + { type: "resume-server" }, + { type: "barrier" }, + + { + type: "assert-consistent", + verify: (state) => { + 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 f3a550c9..c2d6772e 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,45 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; +import type { TestDefinition } from "../test-definition"; -function verifyRenamedAndEdited(state: ClientState): void { - const files = Array.from(state.files.keys()); - assert( - state.files.size === 1, - `Expected 1 file, got ${state.files.size}: ${files.join(", ")}` - ); - assert( - !state.files.has("A.md"), - `A.md should not exist after rename` - ); - assert( - state.files.has("B.md"), - `Expected B.md to exist, got: ${files.join(", ")}` - ); - const content = state.files.get("B.md") ?? ""; - assert( - content === "edited after rename during pause", - `Expected B.md content to be "edited after rename during pause", got: "${content}"` - ); -} - -/** - * Tests that a rename + edit while the server is paused both propagate - * correctly after resume. The event coalescing should produce a - * move-and-update action. When the server resumes and processes the - * stalled request, both the path change and content change should - * apply atomically. - * - * This exercises the coalescing path: move + update = move-and-update. - */ export const serverPauseRenameEditResumeTest: TestDefinition = { - name: "Server Pause: Rename + Edit Then Resume", description: "Client 0 creates A.md and syncs. Server is paused. Client 0 " + "renames A.md to B.md and edits B.md. Server resumes. Both the " + "rename and edit should propagate to Client 1.", clients: 2, steps: [ - // Setup: create and sync { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { @@ -51,16 +18,12 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "A.md", - content: "original content" + type: "assert-consistent", + verify: (s) => s.assertContent("A.md", "original content"), }, - // Pause server { type: "pause-server" }, - // Rename and edit while server is paused { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "update", @@ -69,15 +32,18 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { content: "edited after rename during pause" }, - // Resume server { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Both clients should have B.md with edited content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-consistent", verify: verifyRenamedAndEdited } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileCount(1) + .assertFileNotExists("A.md") + .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 4cb42b5f..3523cf79 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,44 +1,10 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyFinalState(state: ClientState): void { - // The updated file must exist with the new content - assert( - state.files.has("shared.md"), - `Expected shared.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const sharedContent = state.files.get("shared.md") ?? ""; - assert( - sharedContent === "updated during pause", - `Expected shared.md to be "updated during pause", got: "${sharedContent}"` - ); - - // The new file created by client 1 during the pause must also exist - assert( - state.files.has("new-file.md"), - `Expected new-file.md to exist, got: ${Array.from(state.files.keys()).join(", ")}` - ); - const newContent = state.files.get("new-file.md") ?? ""; - assert( - newContent === "created by client 1", - `Expected new-file.md to be "created by client 1", got: "${newContent}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const serverPauseUpdateAndCreateTest: TestDefinition = { - name: "Server Pause — Update and Create Simultaneously", description: - "Client 0 creates shared.md and both clients sync. The server is paused. " + - "Client 0 updates shared.md to new content. Client 1 creates an entirely " + - "new file new-file.md. Both HTTP requests stall. After the server resumes, " + - "the update and the create should both complete. Client 1 should see the " + - "updated content in shared.md, and Client 0 should see new-file.md. " + - "This tests that mixed operation types (update + create) from different " + - "clients both survive a server outage and that the WebSocket reconnection " + - "delivers all missed broadcasts.", + "Client 0 updates a shared file while client 1 creates a new file, both during a server pause. After the server resumes, both operations should complete and propagate to both clients.", clients: 2, steps: [ - // Setup: create shared.md and sync { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { @@ -50,23 +16,18 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, { - type: "assert-content", - client: 1, - path: "shared.md", - content: "initial content" + type: "assert-consistent", + verify: (s) => s.assertContent("shared.md", "initial content"), }, - // Pause the server { type: "pause-server" }, - // Client 0 updates the existing file (stalls) { type: "update", client: 0, path: "shared.md", content: "updated during pause" }, - // Client 1 creates a brand-new file (stalls) { type: "create", client: 1, @@ -74,17 +35,17 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { content: "created by client 1" }, - // Resume server — both operations should complete { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Verify final state - { type: "assert-exists", client: 0, path: "shared.md" }, - { type: "assert-exists", client: 0, path: "new-file.md" }, - { type: "assert-exists", client: 1, path: "shared.md" }, - { type: "assert-exists", client: 1, path: "new-file.md" }, - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + 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 dc16aaee..2e74b3a5 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,61 +1,39 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -function verifyConflictResolution(state: ClientState): void { - // The delete and offline update conflict on the same document. - // Either outcome is acceptable — the key invariant is convergence - // (checked by assert-consistent). But we verify content correctness - // for whichever outcome the system chose. - if (state.files.has("A.md")) { - // Update won: A.md should have the offline-modified content - assert( - state.files.get("A.md") === "modified by 1 while offline", - `If A.md survived, it should have "modified by 1 while offline", got: "${state.files.get("A.md")}"` - ); - assert( - state.files.size === 1, - `Expected exactly 1 file if update won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - } else { - // Delete won: no files should exist - assert( - state.files.size === 0, - `Expected 0 files if delete won, got ${state.files.size}: ${Array.from(state.files.keys()).join(", ")}` - ); - } -} +import type { TestDefinition } from "../test-definition"; export const simultaneousCreateDeleteSamePathTest: TestDefinition = { - name: "Simultaneous Create and Delete at Same Path", description: "Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " + "Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " + "the update and delete must be reconciled. Both clients must converge.", clients: 2, steps: [ - // Setup: Client 0 creates and syncs A.md { 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" }, - // Client 1 goes offline { type: "disable-sync", client: 1 }, - // Client 0 deletes A.md { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - // Client 1 updates A.md while offline (it still has it) { type: "update", client: 1, path: "A.md", content: "modified by 1 while offline" }, - // Client 1 reconnects { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, { type: "barrier" }, - // Both must agree — key invariant is convergence - { type: "assert-consistent", verify: verifyConflictResolution } + { + 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); + } + }, + } ] }; 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 d213d965..d434dde3 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,52 +1,12 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * COMPLEX EDGE CASE: Three clients perform conflicting operations simultaneously. - * - * Client A renames X→Y, Client B deletes X, Client C creates Y. - * This exercises multiple conflict resolution paths at once: - * - * - Client A's rename needs the old path X (which Client B is deleting) - * - Client C's create at Y conflicts with Client A's rename destination - * - The server must handle all three operations arriving in arbitrary order - * - * Expected behavior: - * - The rename from A should succeed (it was initiated before B's delete) - * - B's delete of X is effectively a no-op since A already moved it away - * - C's create at Y triggers a smart merge with A's renamed document - * - Final state: Y exists with merged content from A and C - */ -function verifyFinalState(state: ClientState): void { - // X should not exist (renamed/deleted) - assert( - !state.files.has("X.md"), - `X.md should not exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - - // Y should exist with content from both A's original and C's create - assert( - state.files.has("Y.md"), - `Y.md should exist, files: ${Array.from(state.files.keys()).join(", ")}` - ); - const content = state.files.get("Y.md") ?? ""; - // Both contents should be merged (A's rename + C's create at same path) - assert( - content.includes("original from A") && - content.includes("new from C"), - `Y.md should contain merged content from both A and C, got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const threeClientRenameCreateDeleteTest: TestDefinition = { - name: "Three Clients: Rename + Delete + Create Conflict", description: "Client 0 renames X→Y, Client 1 deletes X, Client 2 creates Y. " + "All three operations happen while the other clients are offline. " + "Tests that the system handles the three-way conflict and converges.", clients: 3, steps: [ - // Setup: Client 0 creates X.md, all sync { type: "create", client: 0, @@ -59,18 +19,14 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // All clients go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "disable-sync", client: 2 }, - // Client 0: rename X→Y { type: "rename", client: 0, oldPath: "X.md", newPath: "Y.md" }, - // Client 1: delete X { type: "delete", client: 1, path: "X.md" }, - // Client 2: create Y with different content { type: "create", client: 2, @@ -78,7 +34,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { content: "new from C" }, - // Bring all clients back online, one at a time { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, @@ -89,7 +44,12 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // All clients should converge - { type: "assert-consistent", verify: verifyFinalState } + { + type: "assert-consistent", + verify: (s) => + s + .assertFileNotExists("X.md") + .assertContains("Y.md", "original from A", "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 774bd23e..43536bed 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,46 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: Events for a currently-processing document may be lost. - * - * Found by: sync-event-queue.ts analysis (agent #3) - * - * In sync-event-queue.ts, when processNext() starts executing an action - * for a document key, it removes the key from documentStates (line 259) - * and sets currentlyProcessing to the key (line 258). - * - * If a new event arrives for the SAME key while the executor is running: - * 1. enqueue() coalesces into documentStates (line 50 or 47) - * 2. Tries to add to processingOrder (line 71-76) - * 3. The guard checks: currentlyProcessing !== key → FALSE - * 4. So the key is NOT added to processingOrder - * 5. When the executor finishes, processNext() picks the NEXT key - * 6. The new event sits in documentStates but is never processed - * - * The system recovers via runFinalConsistencyCheck() which does a fresh - * filesystem scan, but the immediate update is lost until then. - * - * This test creates a file, then updates it while the create is being - * processed (using server pause to control timing). The update should - * be reflected on both clients. - */ -function verifyUpdatedContent(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("file.md"), "Expected file.md to exist"); - const content = state.files.get("file.md") ?? ""; - assert( - content === "updated during create", - `Expected "updated during create", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const updateDuringCreateProcessingTest: TestDefinition = { - name: "Update During Create Processing — Event Not Lost", description: - "Client creates a file, then updates it while the create HTTP request " + - "is in-flight (server paused). The update should eventually propagate " + - "to the other client, not be silently lost in the queue.", + "Client 0 creates a file while the server is paused, then immediately updates it. After the server resumes, both clients should converge with the updated content.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -48,10 +10,8 @@ export const updateDuringCreateProcessingTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Pause server so create stalls mid-processing { type: "pause-server" }, - // Create file (request stalls) { type: "create", client: 0, @@ -59,9 +19,6 @@ export const updateDuringCreateProcessingTest: TestDefinition = { content: "initial" }, - // Wait a bit for the create to enter the executor - - // Update while create is in-flight { type: "update", client: 0, @@ -69,12 +26,14 @@ export const updateDuringCreateProcessingTest: TestDefinition = { content: "updated during create" }, - // Resume server — create completes { type: "resume-server" }, { type: "sync" }, { type: "barrier" }, - // Updated content should be on both clients - { type: "assert-consistent", verify: verifyUpdatedContent } + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("file.md", "updated during create"), + } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts b/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts deleted file mode 100644 index 91769f0d..00000000 --- a/frontend/deterministic-tests/src/tests/update-during-server-pause.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const updateDuringServerPauseTest: TestDefinition = { - name: "Update During Server Pause", - description: - "Client 0 creates a file and syncs. Server is paused. Client 0 updates " + - "the file (request stalls). Server resumes. The update should eventually " + - "propagate to Client 1.", - clients: 2, - steps: [ - // Setup: create and sync - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "create", client: 0, path: "doc.md", content: "v1" }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "doc.md", content: "v1" }, - - // Pause server, update file - { type: "pause-server" }, - { type: "update", client: 0, path: "doc.md", content: "v2 during pause" }, - - // Resume server - { type: "resume-server" }, - { type: "sync" }, - { type: "barrier" }, - - // Both should have updated content - { - type: "assert-content", - client: 0, - path: "doc.md", - content: "v2 during pause" - }, - { - type: "assert-content", - client: 1, - path: "doc.md", - content: "v2 during pause" - }, - { type: "assert-consistent" } - ] -}; 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 4a46343e..5bc713ba 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,60 +1,33 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Local edit must survive a concurrent remote delete. - * - * Scenario: - * 1. Both clients have doc.md = "original" - * 2. Client 0 deletes doc.md - * 3. Client 1 edits doc.md to "edited by client 1" - * 4. Client 0 syncs first (delete reaches server) - * 5. Client 1 syncs — sees remote delete, but local edit takes precedence - * 6. Client 1 creates a NEW document at doc.md with the edited content - */ -function verifyEditSurvived(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md") ?? ""; - assert( - content.includes("edited by client 1"), - `Expected content to include "edited by client 1", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const updateSurvivesRemoteDeleteTest: TestDefinition = { - name: "Local Edit Survives Remote Delete", description: - "When a user edits a file and another client deletes it concurrently, " + - "the local edit should take precedence and the file should survive.", + "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. The edited file should survive on both clients.", clients: 2, steps: [ - // Setup { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Both go offline { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - // Client 0 deletes, client 1 edits { type: "delete", client: 0, path: "doc.md" }, { type: "update", client: 1, path: "doc.md", content: "edited by client 1" }, - // Client 0 goes online first — delete reaches server before - // Client 1 reconnects. This ensures Client 1's update sees - // the remote delete and falls back to creating a new document. { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, - // Client 1 goes online — remote delete coalesces with local edit { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyEditSurvived }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContains("doc.md", "edited by client 1"), + }, ], }; diff --git a/frontend/deterministic-tests/src/tests/update-then-rename.test.ts b/frontend/deterministic-tests/src/tests/update-then-rename.test.ts deleted file mode 100644 index 4588f72e..00000000 --- a/frontend/deterministic-tests/src/tests/update-then-rename.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const updateThenRenameTest: TestDefinition = { - name: "Update Then Rename While Online", - description: - "Client 0 updates A.md then immediately renames it to B.md while online. " + - "Both the content change and rename should propagate to Client 1.", - clients: 2, - steps: [ - // Setup - { type: "create", client: 0, path: "A.md", content: "v1" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - { type: "assert-content", client: 1, path: "A.md", content: "v1" }, - - // Update then rename (both while online) - { type: "update", client: 0, path: "A.md", content: "v2-updated" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "sync" }, - { type: "barrier" }, - - // A.md gone, B.md has updated content - { type: "assert-not-exists", client: 0, path: "A.md" }, - { type: "assert-not-exists", client: 1, path: "A.md" }, - { type: "assert-exists", client: 0, path: "B.md" }, - { type: "assert-exists", client: 1, path: "B.md" }, - { type: "assert-content", client: 0, path: "B.md", content: "v2-updated" }, - { type: "assert-content", client: 1, path: "B.md", content: "v2-updated" }, - { type: "assert-consistent" } - ] -}; 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 4ab1a1f9..202bd437 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,30 +1,8 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG FIX: Watermark must advance even when remote updates are skipped. - * - * When a remote update is skipped (e.g., because the document already - * exists locally, or a pending create covers it), the vaultUpdateId - * must still be recorded via addSeenUpdateId. Otherwise, the watermark - * stalls and every subsequent reconnect replays stale updates. - * - * This test creates a scenario where one client has a pending create - * at the same path as a remote create. The skipped remote create's - * vaultUpdateId must be recorded. After a reconnect cycle, the - * watermark should be past the skipped update. - */ -function verifyConverged(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); -} +import type { TestDefinition } from "../test-definition"; export const watermarkAdvancesOnSkipTest: TestDefinition = { - name: "Watermark Advances When Remote Update Is Skipped", description: - "When a remote update is skipped (already exists, pending create, " + - "etc.), the vaultUpdateId must still be recorded to prevent " + - "watermark stalls and unnecessary replays on reconnect.", + "Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.", clients: 2, steps: [ { type: "enable-sync", client: 0 }, @@ -32,19 +10,16 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - // Both go offline and create at the same path { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "create", client: 0, path: "doc.md", content: "from client 0" }, { type: "create", client: 1, path: "doc.md", content: "from client 1" }, - // Both come online - one will skip the other's remote create { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Disconnect and reconnect to test watermark { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 0 }, @@ -52,6 +27,9 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConverged }, + { + type: "assert-consistent", + verify: (s) => 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 5b525b11..0f5ade3d 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,83 +1,38 @@ -import type { ClientState, TestDefinition } from "../test-definition"; -import { assert } from "../utils/assert"; - -/** - * BUG: executeRemoteUpdate for tracked docs doesn't record the remote - * version's vaultUpdateId. - * - * In sync-actions.ts executeRemoteUpdate (line 1124-1135): - * if (doc?.state === "tracked") { - * if (doc.serverVersion >= remoteVersion.vaultUpdateId) { - * deps.vfs.addSeenUpdateId(remoteVersion.vaultUpdateId); - * return; - * } - * return executeSyncUpdateFull(deps, doc, undefined, true); - * } - * - * When doc.serverVersion < remoteVersion.vaultUpdateId, the code delegates - * to executeSyncUpdateFull WITHOUT first recording remoteVersion.vaultUpdateId. - * executeSyncUpdateFull fetches the latest version from the server, which may - * have a HIGHER vaultUpdateId than the broadcast's. The response's - * vaultUpdateId is recorded, but the broadcast's original vaultUpdateId - * is never recorded — creating a permanent gap in CoveredValues. - * - * Similarly, when remote-update events coalesce (remote-update + - * remote-update = remote-update), the first event's vaultUpdateId - * is replaced by the second's and never recorded. - * - * This causes the watermark to stall, and every reconnect replays - * updates from the stuck point — wasting bandwidth. - * - * This test proves the watermark gap by doing two updates on one client, - * having the other client receive and process them, then disconnecting - * and reconnecting to see if the second sync is a no-op. - */ -function verifyConvergence(state: ClientState): void { - assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); - assert(state.files.has("doc.md"), "Expected doc.md to exist"); - const content = state.files.get("doc.md")!; - assert( - content === "update 2", - `Expected "update 2", got: "${content}"` - ); -} +import type { TestDefinition } from "../test-definition"; export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { - name: "Watermark Gap When Remote Update vaultUpdateId Not Recorded", description: - "When a tracked document receives a remote update and the client " + - "fetches a newer version from the server, the broadcast's original " + - "vaultUpdateId is never recorded. This creates a watermark gap " + - "that causes unnecessary replays on reconnect.", + "Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.", clients: 2, steps: [ - // Setup: both clients have doc.md { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Client 0 sends two rapid updates { 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 }, - // Client 1 processes the broadcasts { type: "sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: verifyConvergence }, + { + type: "assert-consistent", + verify: (s) => + s.assertFileCount(1).assertContent("doc.md", "update 2"), + }, - // Disconnect and reconnect client 1 — the watermark should have - // advanced past both updates. If there's a gap, the server will - // replay the older update, causing unnecessary work. { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - // Verify convergence is maintained after reconnect - { type: "assert-consistent", verify: verifyConvergence } + { + type: "assert-consistent", + verify: (s) => + 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 new file mode 100644 index 00000000..05414342 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -0,0 +1,132 @@ +import type { ClientState } from "../test-definition"; + +export class AssertableState { + readonly files: Map; + readonly clientFiles: Map[]; + + constructor(state: ClientState) { + this.files = state.files; + this.clientFiles = state.clientFiles; + } + + assertFileCount(expected: number): this { + if (this.files.size !== expected) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected ${expected} file(s), got ${this.files.size}: [${keys}]` + ); + } + return this; + } + + 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}]` + ); + } + return this; + } + + assertFileNotExists(path: string): this { + if (this.files.has(path)) { + const keys = Array.from(this.files.keys()).join(", "); + throw new Error( + `Expected "${path}" not to exist. Files: [${keys}]` + ); + } + return this; + } + + assertContent(path: string, expected: string): this { + this.assertFileExists(path); + const actual = this.files.get(path) ?? ""; + if (actual !== expected) { + throw new Error( + `Expected "${path}" to have content "${expected}", got: "${actual}"` + ); + } + return this; + } + + assertContains(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const missing = substrings.filter((s) => !content.includes(s)); + if (missing.length > 0) { + throw new Error( + `Expected "${path}" to contain ${missing.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + assertContainsAny(path: string, ...substrings: string[]): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const found = substrings.some((s) => content.includes(s)); + if (!found) { + throw new Error( + `Expected "${path}" to contain at least one of ${substrings.map((s) => `"${s}"`).join(", ")}. Content: "${content}"` + ); + } + return this; + } + + 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) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected some file to contain ${missing.map((s) => `"${s}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + + assertSubstringCount( + path: string, + substring: string, + expected: number + ): this { + this.assertFileExists(path); + const content = this.files.get(path) ?? ""; + const actual = content.split(substring).length - 1; + if (actual !== expected) { + throw new Error( + `Expected "${substring}" to appear ${expected} time(s) in "${path}", found ${actual}. Content: "${content}"` + ); + } + return this; + } + + assertContentInAtMostOneFile(substring: string): this { + const matches = Array.from(this.files.entries()).filter(([, content]) => + content.includes(substring) + ); + if (matches.length > 1) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected "${substring}" in at most 1 file, found in ${matches.length}: [${matches.map(([p]) => p).join(", ")}].\nFiles:\n${dump}` + ); + } + return this; + } + + ifFileExists(path: string, fn: (state: this) => void): this { + if (this.files.has(path)) { + fn(this); + } + return this; + } + + getContent(path: string): string { + return this.files.get(path) ?? ""; + } +}