This commit is contained in:
Andras Schmelczer 2025-11-30 15:25:20 +00:00
parent 829a16aa77
commit 9015c78598
9 changed files with 1097 additions and 8 deletions

View file

@ -123,6 +123,9 @@ export class Locks<T> {
*/
public unlock(key: T): void {
if (!this.locked.has(key)) {
this.logger?.warn(
`Attempted to unlock key "${key}" which is not currently locked`
);
return;
}

View file

@ -8,7 +8,9 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts"
"test": "tsx --test src/**/*.test.ts",
"test:deterministic": "npm run build && node dist/deterministic/cli.js",
"test:fuzzing": "npm run build && node dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.8.1",

View file

@ -0,0 +1,68 @@
import { v4 as uuidv4 } from "uuid";
import { DeterministicTestRunner } from "./test-runner";
import { exampleTests } from "./example-tests";
const REMOTE_URI = "http://localhost:3000";
const TOKEN = "test-token-change-me";
async function runDeterministicTests(): Promise<void> {
console.info("=".repeat(80));
console.info("DETERMINISTIC E2E TESTS");
console.info("=".repeat(80));
console.info("");
let passed = 0;
let failed = 0;
for (const testDef of exampleTests) {
// Use a unique vault for each test to avoid interference
const vaultName = uuidv4();
const runner = new DeterministicTestRunner(
vaultName,
REMOTE_URI,
TOKEN
);
try {
await runner.runTest(testDef);
passed++;
} catch (error) {
failed++;
console.error(`Test "${testDef.name}" failed with error:`, error);
}
}
console.info("\n" + "=".repeat(80));
console.info("TEST SUMMARY");
console.info("=".repeat(80));
console.info(`Total tests: ${exampleTests.length}`);
console.info(`Passed: ${passed}`);
console.info(`Failed: ${failed}`);
console.info("=".repeat(80));
if (failed > 0) {
process.exit(1);
}
}
// Error handlers
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exit(1);
});
process.on("unhandledRejection", (error) => {
console.error("Unhandled rejection:", error);
process.exit(1);
});
// Run tests
runDeterministicTests()
.then(() => {
console.info("\n✓ All deterministic tests passed!");
process.exit(0);
})
.catch((error: unknown) => {
console.error("\n✗ Deterministic tests failed:", error);
process.exit(1);
});

View file

@ -0,0 +1,241 @@
import type { RelativePath, SyncSettings } from "sync-client";
import { MockClient } from "../agent/mock-client";
import { assert } from "../utils/assert";
export class DeterministicClient extends MockClient {
private pendingOperations: (() => Promise<void>)[] = [];
public constructor(
public readonly clientId: string,
initialSettings: Partial<SyncSettings>
) {
super(initialSettings, false);
}
/**
* Get the underlying SyncClient
*/
public getSyncClient() {
return this.client;
}
/**
* Create a file with specific content
*/
public async createFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.create(path, new TextEncoder().encode(content));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Update a file with new content (replaces all content)
*/
public async updateFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.write(path, new TextEncoder().encode(content));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Append content to a file
*/
public async appendToFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.atomicUpdateText(path, (current) => ({
text: current.text + content,
cursors: []
}));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Delete a file
*/
public async deleteFile(
path: RelativePath,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.delete(path);
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Rename a file
*/
public async renameFile(
oldPath: RelativePath,
newPath: RelativePath,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.rename(oldPath, newPath);
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Flush all pending operations
*/
public async flush(): Promise<void> {
const operations = [...this.pendingOperations];
this.pendingOperations = [];
for (const operation of operations) {
await operation();
}
}
/**
* Wait until all sync operations are complete
*/
public async waitForSync(): Promise<void> {
await this.client.waitUntilFinished();
}
/**
* Enable or disable sync
*/
public async setSyncEnabled(enabled: boolean): Promise<void> {
await this.client.setSetting("isSyncEnabled", enabled);
}
/**
* Get file content as string
*/
public async getFileContent(path: RelativePath): Promise<string> {
const content = await this.read(path);
return new TextDecoder().decode(content);
}
/**
* Get number of files
*/
public async getFileCount(): Promise<number> {
const files = await this.listFilesRecursively();
return files.length;
}
/**
* Assert file exists or doesn't exist
*/
public async assertFileExists(
path: RelativePath,
shouldExist: boolean
): Promise<void> {
const exists = await this.exists(path);
assert(
exists === shouldExist,
`[${this.clientId}] Expected file ${path} to ${shouldExist ? "exist" : "not exist"}, but it ${exists ? "exists" : "doesn't exist"}`
);
}
/**
* Assert file content matches expected
*/
public async assertFileContent(
path: RelativePath,
expectedContent: string
): Promise<void> {
const content = await this.getFileContent(path);
assert(
content === expectedContent,
`[${this.clientId}] Expected file ${path} to have content "${expectedContent}", but it has "${content}"`
);
}
/**
* Assert file count matches expected
*/
public async assertFileCount(expectedCount: number): Promise<void> {
const count = await this.getFileCount();
assert(
count === expectedCount,
`[${this.clientId}] Expected ${expectedCount} files, but found ${count}`
);
}
/**
* Check if this client's filesystem is consistent with another client
*/
public async assertConsistentWith(
otherClient: DeterministicClient
): Promise<void> {
const thisFiles = await this.listFilesRecursively();
const otherFiles = await otherClient.listFilesRecursively();
const thisFilesSet = new Set(thisFiles);
const otherFilesSet = new Set(otherFiles);
const missingInOther = thisFiles.filter((f) => !otherFilesSet.has(f));
const missingInThis = otherFiles.filter((f) => !thisFilesSet.has(f));
assert(
missingInOther.length === 0,
`[${this.clientId}] Files missing in ${otherClient.clientId}: ${missingInOther.join(", ")}`
);
assert(
missingInThis.length === 0,
`[${this.clientId}] Files missing in this client from ${otherClient.clientId}: ${missingInThis.join(", ")}`
);
// Check content of all files
for (const file of thisFiles) {
const thisContent = await this.getFileContent(file);
const otherContent = await otherClient.getFileContent(file);
assert(
thisContent === otherContent,
`[${this.clientId}] Content mismatch for ${file}:\n This: "${thisContent}"\n Other: "${otherContent}"`
);
}
}
/**
* Cleanup
*/
public async destroy(): Promise<void> {
await this.client.destroy();
}
}

View file

@ -0,0 +1,143 @@
import type { RelativePath } from "sync-client";
/**
* Base event interface
*/
export interface BaseEvent {
type: string;
description?: string;
}
/**
* File operation events
*/
export interface CreateFileEvent extends BaseEvent {
type: "create-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean; // If true, sync immediately; if false, defer until flush
}
export interface UpdateFileEvent extends BaseEvent {
type: "update-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean;
}
export interface DeleteFileEvent extends BaseEvent {
type: "delete-file";
clientId: string;
path: RelativePath;
immediate?: boolean;
}
export interface RenameFileEvent extends BaseEvent {
type: "rename-file";
clientId: string;
oldPath: RelativePath;
newPath: RelativePath;
immediate?: boolean;
}
export interface AppendToFileEvent extends BaseEvent {
type: "append-to-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean;
}
/**
* Sync control events
*/
export interface FlushEvent extends BaseEvent {
type: "flush";
clientId: string;
}
export interface WaitForSyncEvent extends BaseEvent {
type: "wait-for-sync";
clientId?: string; // If undefined, wait for all clients
}
export interface EnableSyncEvent extends BaseEvent {
type: "enable-sync";
clientId: string;
}
export interface DisableSyncEvent extends BaseEvent {
type: "disable-sync";
clientId: string;
}
/**
* Timing events
*/
export interface SleepEvent extends BaseEvent {
type: "sleep";
milliseconds: number;
}
/**
* Assertion events
*/
export interface AssertFileExistsEvent extends BaseEvent {
type: "assert-file-exists";
clientId: string;
path: RelativePath;
shouldExist: boolean;
}
export interface AssertFileContentEvent extends BaseEvent {
type: "assert-file-content";
clientId: string;
path: RelativePath;
expectedContent: string;
}
export interface AssertFileCountEvent extends BaseEvent {
type: "assert-file-count";
clientId: string;
expectedCount: number;
}
export interface AssertAllClientsConsistentEvent extends BaseEvent {
type: "assert-all-clients-consistent";
}
export interface AssertClientsConsistentEvent extends BaseEvent {
type: "assert-clients-consistent";
clientIds: string[];
}
/**
* Union type of all events
*/
export type TestEvent =
| CreateFileEvent
| UpdateFileEvent
| DeleteFileEvent
| RenameFileEvent
| AppendToFileEvent
| FlushEvent
| WaitForSyncEvent
| EnableSyncEvent
| DisableSyncEvent
| SleepEvent
| AssertFileExistsEvent
| AssertFileContentEvent
| AssertFileCountEvent
| AssertAllClientsConsistentEvent
| AssertClientsConsistentEvent;
/**
* Test definition
*/
export interface TestDefinition {
name: string;
clients: string[]; // Client IDs
events: TestEvent[];
}

View file

@ -0,0 +1,350 @@
import type { TestDefinition } from "./events";
/**
* Simple test: Create a file on one client and verify it syncs to another
*/
export const simpleSync: TestDefinition = {
name: "Simple sync between two clients",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "test.md",
content: "Hello, world!",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for all clients to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "test.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "assert-file-content",
clientId: "client2",
path: "test.md",
expectedContent: "Hello, world!",
description: "Verify content matches on Client 2"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test concurrent edits to the same file
*/
export const concurrentEdits: TestDefinition = {
name: "Concurrent edits with operational transformation",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "collaborative.md",
content: "Initial content ",
description: "Client 1 creates initial file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "disable-sync",
clientId: "client1",
description: "Disable sync on Client 1"
},
{
type: "disable-sync",
clientId: "client2",
description: "Disable sync on Client 2"
},
{
type: "append-to-file",
clientId: "client1",
path: "collaborative.md",
content: "EditA ",
description: "Client 1 appends offline"
},
{
type: "append-to-file",
clientId: "client2",
path: "collaborative.md",
content: "EditB ",
description: "Client 2 appends offline"
},
{
type: "enable-sync",
clientId: "client1",
description: "Re-enable sync on Client 1"
},
{
type: "enable-sync",
clientId: "client2",
description: "Re-enable sync on Client 2"
},
{
type: "wait-for-sync",
description: "Wait for conflict resolution"
},
{
type: "assert-all-clients-consistent",
description: "Verify both clients converged to same state"
}
]
};
/**
* Test file deletion propagation
*/
export const fileDeletion: TestDefinition = {
name: "File deletion syncs correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "to-delete.md",
content: "This file will be deleted",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "to-delete.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "delete-file",
clientId: "client1",
path: "to-delete.md",
description: "Client 1 deletes the file"
},
{
type: "wait-for-sync",
description: "Wait for deletion to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "to-delete.md",
shouldExist: false,
description: "Verify file deleted on Client 2"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test file rename propagation
*/
export const fileRename: TestDefinition = {
name: "File rename syncs correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "old-name.md",
content: "Content that should persist",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "old-name.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "rename-file",
clientId: "client1",
oldPath: "old-name.md",
newPath: "new-name.md",
description: "Client 1 renames the file"
},
{
type: "wait-for-sync",
description: "Wait for rename to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "old-name.md",
shouldExist: false,
description: "Verify old name doesn't exist on Client 2"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "new-name.md",
shouldExist: true,
description: "Verify new name exists on Client 2"
},
{
type: "assert-file-content",
clientId: "client2",
path: "new-name.md",
expectedContent: "Content that should persist",
description: "Verify content preserved"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test deferred operations (batching)
*/
export const deferredOperations: TestDefinition = {
name: "Deferred operations batch correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "file1.md",
content: "File 1",
immediate: false,
description: "Queue creation of file 1 (not synced yet)"
},
{
type: "create-file",
clientId: "client1",
path: "file2.md",
content: "File 2",
immediate: false,
description: "Queue creation of file 2 (not synced yet)"
},
{
type: "create-file",
clientId: "client1",
path: "file3.md",
content: "File 3",
immediate: false,
description: "Queue creation of file 3 (not synced yet)"
},
{
type: "sleep",
milliseconds: 100,
description: "Wait a bit (files shouldn't sync yet)"
},
{
type: "assert-file-count",
clientId: "client2",
expectedCount: 0,
description: "Verify Client 2 has no files yet"
},
{
type: "flush",
clientId: "client1",
description: "Flush pending operations on Client 1"
},
{
type: "wait-for-sync",
description: "Wait for all files to sync"
},
{
type: "assert-file-count",
clientId: "client2",
expectedCount: 3,
description: "Verify Client 2 now has all 3 files"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test offline editing and conflict resolution
*/
export const offlineEditing: TestDefinition = {
name: "Offline editing and reconnection",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "shared.md",
content: "Initial",
description: "Client 1 creates initial file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "disable-sync",
clientId: "client2",
description: "Client 2 goes offline"
},
{
type: "update-file",
clientId: "client1",
path: "shared.md",
content: "Updated by client 1",
description: "Client 1 updates while Client 2 offline"
},
{
type: "wait-for-sync",
clientId: "client1",
description: "Client 1 syncs"
},
{
type: "update-file",
clientId: "client2",
path: "shared.md",
content: "Updated by client 2 offline",
description: "Client 2 updates while offline"
},
{
type: "enable-sync",
clientId: "client2",
description: "Client 2 comes back online"
},
{
type: "wait-for-sync",
description: "Wait for sync and conflict resolution"
},
{
type: "assert-all-clients-consistent",
description: "Verify clients converged after reconnection"
}
]
};
/**
* All example tests
*/
export const exampleTests: TestDefinition[] = [
simpleSync,
concurrentEdits,
fileDeletion,
fileRename,
deferredOperations,
offlineEditing
];

View file

@ -0,0 +1,4 @@
export type * from "./events";
export * from "./test-runner";
export * from "./deterministic-client";
export * from "./example-tests";

View file

@ -0,0 +1,263 @@
import type { SyncSettings } from "sync-client";
import { debugging, Logger } from "sync-client";
import type { TestDefinition, TestEvent } from "./events";
import { DeterministicClient } from "./deterministic-client";
import { sleep } from "../utils/sleep";
import { assert } from "../utils/assert";
export class DeterministicTestRunner {
private readonly clients = new Map<string, DeterministicClient>();
private readonly jitterScaleInSeconds = 0.1; // Small jitter for realism
public constructor(
private readonly vaultName: string,
private readonly remoteUri: string,
private readonly token: string
) {}
/**
* Run a test definition
*/
public async runTest(testDef: TestDefinition): Promise<void> {
console.info(`\n${"=".repeat(60)}`);
console.info(`Running test: ${testDef.name}`);
console.info(`${"=".repeat(60)}\n`);
try {
// Initialize clients
await this.initializeClients(testDef.clients);
// Execute events in sequence
for (let i = 0; i < testDef.events.length; i++) {
const event = testDef.events[i];
await this.executeEvent(event, i);
}
console.info(`\n✓ Test passed: ${testDef.name}\n`);
} catch (error) {
console.error(`\n✗ Test failed: ${testDef.name}`);
console.error(`Error: ${error}\n`);
throw error;
} finally {
await this.cleanup();
}
}
/**
* Initialize all clients for the test
*/
private async initializeClients(clientIds: string[]): Promise<void> {
console.info(`Initializing ${clientIds.length} clients...`);
for (const clientId of clientIds) {
const settings: Partial<SyncSettings> = {
isSyncEnabled: true,
token: this.token,
vaultName: this.vaultName,
syncConcurrency: 16,
remoteUri: this.remoteUri
};
const client = new DeterministicClient(clientId, settings);
await client.init(
debugging.slowFetchFactory(this.jitterScaleInSeconds),
debugging.slowWebSocketFactory(
this.jitterScaleInSeconds,
new Logger()
)
);
// Verify connection
const connectionCheck = await client
.getSyncClient()
.checkConnection();
assert(
connectionCheck.isSuccessful,
`Failed to connect client ${clientId}`
);
this.clients.set(clientId, client);
console.info(` ✓ Initialized client: ${clientId}`);
}
console.info("");
}
/**
* Execute a single event
*/
private async executeEvent(event: TestEvent, index: number): Promise<void> {
const description = event.description ?? event.type;
console.info(`[${index}] ${description}`);
switch (event.type) {
case "create-file": {
const client = this.getClient(event.clientId);
await client.createFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "update-file": {
const client = this.getClient(event.clientId);
await client.updateFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "delete-file": {
const client = this.getClient(event.clientId);
await client.deleteFile(event.path, event.immediate ?? true);
break;
}
case "rename-file": {
const client = this.getClient(event.clientId);
await client.renameFile(
event.oldPath,
event.newPath,
event.immediate ?? true
);
break;
}
case "append-to-file": {
const client = this.getClient(event.clientId);
await client.appendToFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "flush": {
const client = this.getClient(event.clientId);
await client.flush();
break;
}
case "wait-for-sync": {
if (event.clientId) {
const client = this.getClient(event.clientId);
await client.waitForSync();
} else {
// Wait for all clients
await Promise.all(
Array.from(this.clients.values()).map(async (c) =>
c.waitForSync()
)
);
}
break;
}
case "enable-sync": {
const client = this.getClient(event.clientId);
await client.setSyncEnabled(true);
break;
}
case "disable-sync": {
const client = this.getClient(event.clientId);
await client.setSyncEnabled(false);
break;
}
case "sleep": {
await sleep(event.milliseconds);
break;
}
case "assert-file-exists": {
const client = this.getClient(event.clientId);
await client.assertFileExists(event.path, event.shouldExist);
console.info(
` ✓ Assertion passed: ${event.path} ${event.shouldExist ? "exists" : "doesn't exist"}`
);
break;
}
case "assert-file-content": {
const client = this.getClient(event.clientId);
await client.assertFileContent(
event.path,
event.expectedContent
);
console.info(
` ✓ Assertion passed: ${event.path} has expected content`
);
break;
}
case "assert-file-count": {
const client = this.getClient(event.clientId);
await client.assertFileCount(event.expectedCount);
console.info(
` ✓ Assertion passed: ${event.expectedCount} files`
);
break;
}
case "assert-all-clients-consistent": {
const clientList = Array.from(this.clients.values());
for (let i = 0; i < clientList.length - 1; i++) {
await clientList[i].assertConsistentWith(clientList[i + 1]);
}
console.info(` ✓ Assertion passed: all clients consistent`);
break;
}
case "assert-clients-consistent": {
const clientList = event.clientIds.map((id) =>
this.getClient(id)
);
for (let i = 0; i < clientList.length - 1; i++) {
await clientList[i].assertConsistentWith(clientList[i + 1]);
}
console.info(
` ✓ Assertion passed: clients ${event.clientIds.join(", ")} consistent`
);
break;
}
default: {
// @ts-expect-error - exhaustive check
throw new Error(`Unknown event type: ${event.type}`);
}
}
}
/**
* Get a client by ID
*/
private getClient(clientId: string): DeterministicClient {
const client = this.clients.get(clientId);
if (!client) {
throw new Error(`Client ${clientId} not found`);
}
return client;
}
/**
* Cleanup all clients
*/
private async cleanup(): Promise<void> {
console.info("Cleaning up clients...");
for (const [id, client] of this.clients) {
try {
await client.destroy();
console.info(` ✓ Destroyed client: ${id}`);
} catch (error) {
console.error(` ✗ Failed to destroy client ${id}: ${error}`);
}
}
this.clients.clear();
}
}

View file

@ -1,8 +1,7 @@
const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: "./src/cli.ts",
const baseConfig = {
target: "node",
mode: "production",
optimization: {
@ -19,12 +18,28 @@ module.exports = {
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "cli.js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
};
module.exports = [
{
...baseConfig,
entry: "./src/cli.ts",
output: {
globalObject: "this",
filename: "cli.js",
path: path.resolve(__dirname, "dist")
}
},
{
...baseConfig,
entry: "./src/deterministic/cli.ts",
output: {
globalObject: "this",
filename: "deterministic/cli.js",
path: path.resolve(__dirname, "dist")
}
}
];