Clean up deterministic tests
This commit is contained in:
parent
7b9287ca52
commit
f36a84b275
113 changed files with 1366 additions and 3835 deletions
|
|
@ -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
|
||||
};
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
}
|
||||
|
||||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<NamedTestResult> {
|
||||
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<NamedTestResult> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<StoredDatabase>;
|
||||
}> = {};
|
||||
private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT;
|
||||
private readonly syncErrors: Error[] = [];
|
||||
private readonly pendingSyncOperations = new Set<Promise<void>>();
|
||||
|
||||
public constructor(
|
||||
clientId: number,
|
||||
|
|
@ -81,10 +83,12 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
const contentBytes = new TextEncoder().encode(content);
|
||||
this.files.set(path, contentBytes);
|
||||
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () =>
|
||||
this.client.syncLocallyCreatedFile(path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateFile(path: string, content: string): Promise<void> {
|
||||
this.log(`Updating file ${path} with content: ${content}`);
|
||||
|
|
@ -96,10 +100,12 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
const contentBytes = new TextEncoder().encode(content);
|
||||
this.files.set(path, contentBytes);
|
||||
|
||||
if (this.isSyncEnabled) {
|
||||
this.enqueueSync(async () =>
|
||||
this.client.syncLocallyUpdatedFile({ relativePath: path })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async renameFile(oldPath: string, newPath: string): Promise<void> {
|
||||
this.log(`Renaming file ${oldPath} to ${newPath}`);
|
||||
|
|
@ -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<void> {
|
||||
this.log("Waiting for sync to complete...");
|
||||
// Drain agent-level sync operations first. These are the fire-and-forget
|
||||
// promises from enqueueSync() that call into the SyncClient's methods.
|
||||
// Without this, waitUntilFinished() might return before the SyncClient
|
||||
// has even been told about the operation.
|
||||
await this.drainPendingSyncOperations();
|
||||
await withTimeout(
|
||||
this.client.waitUntilFinished(),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} waitForSync timed out after ${WAIT_TIMEOUT_MS}ms`
|
||||
);
|
||||
if (this.syncErrors.length > 0) {
|
||||
const errors = this.syncErrors.splice(0);
|
||||
throw new Error(
|
||||
`Client ${this.clientId} had ${errors.length} sync error(s):\n${errors.map((e) => e.message).join("\n")}`
|
||||
);
|
||||
}
|
||||
this.log("Sync complete");
|
||||
}
|
||||
|
||||
public async disableSync(): Promise<void> {
|
||||
this.log("Disabling sync");
|
||||
// Drain pending enqueued operations before disabling so the SyncClient
|
||||
// knows about all operations that were enqueued while sync was enabled.
|
||||
await this.drainPendingSyncOperations();
|
||||
await this.client.setSetting("isSyncEnabled", false);
|
||||
this.isSyncEnabled = false;
|
||||
// Wait for in-flight operations to drain. Disabling sync triggers
|
||||
// a reset, which aborts in-flight fetches with SyncResetError.
|
||||
try {
|
||||
await withTimeout(
|
||||
this.client.waitUntilFinished(),
|
||||
WAIT_TIMEOUT_MS,
|
||||
`Client ${this.clientId} disableSync drain timed out`
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "SyncResetError") {
|
||||
this.log("Disable sync drain interrupted by reset (expected)");
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async enableSync(): Promise<void> {
|
||||
|
|
@ -161,44 +191,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
|||
await this.waitForWebSocket();
|
||||
}
|
||||
|
||||
public async assertContent(
|
||||
path: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
this.log(`Asserting ${path} exists`);
|
||||
const exists = await this.exists(path);
|
||||
assert(
|
||||
exists,
|
||||
`File ${path} does not exist on client ${this.clientId}`
|
||||
);
|
||||
this.log(`✓ File ${path} exists`);
|
||||
}
|
||||
|
||||
public async assertNotExists(path: string): Promise<void> {
|
||||
this.log(`Asserting ${path} does not exist`);
|
||||
const exists = await this.exists(path);
|
||||
assert(
|
||||
!exists,
|
||||
`File ${path} exists on client ${this.clientId} but should not`
|
||||
);
|
||||
this.log(`✓ File ${path} does not exist`);
|
||||
}
|
||||
|
||||
public async getFiles(): Promise<RelativePath[]> {
|
||||
return this.listFilesRecursively();
|
||||
}
|
||||
|
|
@ -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<Uint8Array> {
|
||||
await Promise.resolve();
|
||||
return super.read(path);
|
||||
}
|
||||
|
||||
public override async write(
|
||||
path: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
await Promise.resolve();
|
||||
return super.write(path, content);
|
||||
}
|
||||
|
||||
public override async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
await Promise.resolve();
|
||||
return super.atomicUpdateText(path, updater);
|
||||
}
|
||||
|
||||
public override async exists(path: RelativePath): Promise<boolean> {
|
||||
await Promise.resolve();
|
||||
return super.exists(path);
|
||||
}
|
||||
|
||||
public override async delete(path: RelativePath): Promise<void> {
|
||||
await Promise.resolve();
|
||||
return super.delete(path);
|
||||
}
|
||||
|
||||
public override async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
await Promise.resolve();
|
||||
return super.rename(oldPath, newPath);
|
||||
}
|
||||
|
||||
private async waitForWebSocket(): Promise<void> {
|
||||
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<void> {
|
||||
while (this.pendingSyncOperations.size > 0) {
|
||||
await Promise.all(this.pendingSyncOperations);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueueSync(operation: () => Promise<void>): 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export class ServerControl {
|
|||
public async waitForReady(maxAttempts = 50): Promise<void> {
|
||||
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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ServerControl } from "./server-control";
|
||||
import type { ServerControl } from "./server-control";
|
||||
import type { Logger } from "sync-client";
|
||||
|
||||
export class ServerManager {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { AssertableState } from "./utils/assertable-state";
|
||||
|
||||
export interface ClientState {
|
||||
files: Map<string, string>;
|
||||
clientFiles: Map<string, string>[];
|
||||
}
|
||||
|
||||
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[];
|
||||
|
|
|
|||
|
|
@ -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<Record<string, TestDefinition>> = {
|
||||
"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<Record<string, TestDefinition>> = {
|
|||
"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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TestResult> {
|
||||
public async runTest(
|
||||
name: string,
|
||||
test: TestDefinition
|
||||
): Promise<TestResult> {
|
||||
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.
|
||||
* Wait for all agents to be simultaneously idle.
|
||||
*
|
||||
* 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.
|
||||
* Completing work on agent A can trigger a server broadcast that
|
||||
* enqueues new work on agent B, which can cascade further. With N
|
||||
* agents the worst-case cascade depth is N (a chain A→B→C→…→A),
|
||||
* so we run N+1 sequential passes to drain it. Extra passes are
|
||||
* essentially free when there is no outstanding work.
|
||||
*
|
||||
* The outer {@link waitForConvergence} loop with consistency checks
|
||||
* remains the ultimate guarantee — this method just minimizes how
|
||||
* many slow retry iterations are needed.
|
||||
*/
|
||||
private async waitAllAgentsSettled(): Promise<void> {
|
||||
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<void> {
|
||||
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<string, string>[] = [];
|
||||
for (const agent of this.agents) {
|
||||
const sortedFiles = (await agent.getFiles()).sort();
|
||||
const fileMap = new Map<string, string>();
|
||||
for (const file of sortedFiles) {
|
||||
const content = await agent.getFileContent(file);
|
||||
fileMap.set(file, content);
|
||||
}
|
||||
clientFiles.push(fileMap);
|
||||
}
|
||||
|
||||
const 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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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") }
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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 " +
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue