Add deterministic tests and lint

This commit is contained in:
Andras Schmelczer 2026-01-13 21:52:42 +00:00
parent ea5a123cb8
commit 16afe31e89
29 changed files with 1738 additions and 222 deletions

View file

@ -0,0 +1,233 @@
#!/usr/bin/env node
import { TestRunner } from "./test-runner";
import { ServerControl } from "./server-control";
import type { TestDefinition } from "./test-definition";
import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
import * as path from "node:path";
import * as fs from "node:fs";
// Global error handlers to catch unhandled errors
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise);
console.error("Reason:", reason);
process.exit(1);
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
process.exit(1);
});
// Available tests - using Partial to allow undefined lookup
const TESTS: Partial<Record<string, TestDefinition>> = {
"write-write-conflict": writeWriteConflictTest,
"rename-create-conflict": renameCreateConflictTest
};
function printHelp(): void {
console.log(`
Deterministic Test Runner for VaultLink
Usage:
npm run test [options]
Options:
--test <name> Run specific test (or "all")
--list List available tests
--server <path> Path to sync_server binary (default: auto-detect)
--config <path> Path to config file (default: config-e2e.yml)
--no-manage-server Don't start/stop server (assume it's running)
--help, -h Show this help
Examples:
npm run test
npm run test -- --test write-write-conflict
npm run test -- --test all
npm run test -- --list
npm run test -- --no-manage-server --test rename-create-conflict
`);
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Parse arguments
let testName: string | undefined = undefined;
let serverPath: string | undefined = undefined;
let configPath: string | undefined = undefined;
let manageServer = true;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--test" && i + 1 < args.length) {
testName = args[++i];
} else if (arg === "--server" && i + 1 < args.length) {
serverPath = args[++i];
} else if (arg === "--config" && i + 1 < args.length) {
configPath = args[++i];
} else if (arg === "--no-manage-server") {
manageServer = false;
} else if (arg === "--list") {
console.log("\nAvailable tests:");
for (const [name, test] of Object.entries(TESTS)) {
if (test !== undefined) {
console.log(` ${name}: ${test.description ?? test.name}`);
}
}
process.exit(0);
} else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
// Default values
if (serverPath === undefined) {
// Try to find project root from current working directory
const cwd = process.cwd();
let projectRoot = cwd;
// If we're in frontend/deterministic-tests, go up two levels
if (
cwd.endsWith("frontend/deterministic-tests") ||
cwd.endsWith("frontend\\deterministic-tests")
) {
projectRoot = path.resolve(cwd, "../..");
}
// If we're in frontend, go up one level
else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) {
projectRoot = path.resolve(cwd, "..");
}
serverPath = path.join(
projectRoot,
"sync-server/target/debug/sync_server"
);
// Check if server binary exists
if (!fs.existsSync(serverPath)) {
console.error(`Server binary not found at: ${serverPath}`);
console.error(
"Please build the server first: cd sync-server && cargo build"
);
console.error(`Current working directory: ${cwd}`);
console.error(`Project root detected as: ${projectRoot}`);
process.exit(1);
}
}
if (configPath === undefined) {
const cwd = process.cwd();
let projectRoot = cwd;
if (
cwd.endsWith("frontend/deterministic-tests") ||
cwd.endsWith("frontend\\deterministic-tests")
) {
projectRoot = path.resolve(cwd, "../..");
} else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) {
projectRoot = path.resolve(cwd, "..");
}
configPath = path.join(projectRoot, "sync-server/config-e2e.yml");
if (!fs.existsSync(configPath)) {
console.error(`Config file not found at: ${configPath}`);
process.exit(1);
}
}
// Determine which tests to run
const testsToRun: TestDefinition[] = [];
// Collect all defined tests
const allTests: TestDefinition[] = [];
for (const test of Object.values(TESTS)) {
if (test !== undefined) {
allTests.push(test);
}
}
if (testName !== undefined) {
if (testName === "all") {
testsToRun.push(...allTests);
} else {
const test = TESTS[testName];
if (test === undefined) {
console.error(`Unknown test: ${testName}`);
console.error(
`Available tests: ${Object.keys(TESTS).join(", ")}, all`
);
process.exit(1);
}
testsToRun.push(test);
}
} else {
// Default: run all tests
testsToRun.push(...allTests);
}
console.log(`\nDeterministic Test Suite`);
console.log("=".repeat(80));
console.log(`Server: ${serverPath}`);
console.log(`Config: ${configPath}`);
console.log(`Manage server: ${manageServer}`);
console.log(`Tests to run: ${testsToRun.length}`);
console.log(`${"=".repeat(80)}\n`);
// Initialize server control
const serverControl = new ServerControl(serverPath, configPath);
let allPassed = true;
try {
// Start server if we're managing it
if (manageServer) {
await serverControl.start();
} else {
console.log("Assuming server is already running...");
await serverControl.waitForReady();
}
// Run tests
for (const test of testsToRun) {
const runner = new TestRunner(serverControl);
const result = await runner.runTest(test);
if (!result.success) {
allPassed = false;
console.error(`\n✗ FAILED: ${test.name}`);
console.error(`Error: ${result.error}`);
} else {
console.log(`\n✓ PASSED: ${test.name} (${result.duration}ms)`);
}
// Add delay between tests
if (testsToRun.indexOf(test) < testsToRun.length - 1) {
console.log("\nWaiting 2s before next test...\n");
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
} finally {
// Stop server if we're managing it
if (manageServer) {
await serverControl.stop();
}
}
console.log(`\n${"=".repeat(80)}`);
if (allPassed) {
console.log("✓ All tests passed!");
process.exit(0);
} else {
console.log("✗ Some tests failed");
process.exit(1);
}
}
main().catch((err: unknown) => {
console.error("Unexpected error:", err);
process.exit(1);
});

View file

@ -0,0 +1,267 @@
import type { StoredDatabase, TextWithCursors } from "sync-client";
import type {
RelativePath,
FileSystemOperations,
SyncSettings
} from "sync-client";
import { SyncClient } from "sync-client";
import { assert } from "./utils/assert";
/**
* DeterministicAgent - A test agent that properly awaits all sync operations.
*
* Unlike MockClient which fires-and-forgets sync operations, this class
* ensures each operation is fully registered with SyncClient before returning.
*/
export class DeterministicAgent implements FileSystemOperations {
public readonly clientId: number;
private readonly logger: (msg: string) => void;
private readonly localFiles = new Map<string, Uint8Array>();
private client!: SyncClient;
private data: Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}> = {};
// Track sync state locally to avoid calling sync methods when disabled
private isSyncEnabled = true;
public constructor(
clientId: number,
initialSettings: Partial<SyncSettings>,
logger: (msg: string) => void
) {
this.clientId = clientId;
this.logger = logger;
this.data.settings = initialSettings;
this.isSyncEnabled = initialSettings.isSyncEnabled !== false;
}
public async init(
fetchImplementation: typeof globalThis.fetch,
webSocketImplementation: typeof globalThis.WebSocket
): Promise<void> {
this.client = await SyncClient.create({
fs: this,
persistence: {
load: async () => this.data,
save: async (data) => void (this.data = data)
},
fetch: fetchImplementation,
webSocket: webSocketImplementation
});
await this.client.start();
// Verify connection is working
const connectionCheck = await this.client.checkConnection();
assert(
connectionCheck.isSuccessful,
`Client ${this.clientId} connection check failed`
);
}
// FileSystemOperations implementation
public async listFilesRecursively(
_root?: RelativePath
): Promise<RelativePath[]> {
return Array.from(this.localFiles.keys());
}
public async read(path: RelativePath): Promise<Uint8Array> {
const file = this.localFiles.get(path);
if (!file) {
throw new Error(`File ${path} does not exist`);
}
return file;
}
public async getFileSize(path: RelativePath): Promise<number> {
return (await this.read(path)).length;
}
public async exists(path: RelativePath): Promise<boolean> {
return this.localFiles.has(path);
}
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
// This is called by SyncClient to write files received from the server.
// Do NOT call sync methods here - that would create a feedback loop.
this.localFiles.set(path, content);
}
public async createDirectory(_path: RelativePath): Promise<void> {
// Virtual FS doesn't need directories
}
public async atomicUpdateText(
path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors
): Promise<string> {
// This is called by SyncClient (via FileOperations.write) during merge handling.
// Do NOT call sync methods here - that would create a deadlock.
const file = this.localFiles.get(path);
if (!file) {
throw new Error(`File ${path} does not exist`);
}
const currentContent = new TextDecoder().decode(file);
const newContent = updater({ text: currentContent, cursors: [] }).text;
this.localFiles.set(path, new TextEncoder().encode(newContent));
return newContent;
}
public async delete(path: RelativePath): Promise<void> {
// This is called by SyncClient to delete files.
// Do NOT call sync methods here - that would create a feedback loop.
this.localFiles.delete(path);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
// This is called by SyncClient to rename files.
// Do NOT call sync methods here - that would create a feedback loop.
const file = this.localFiles.get(oldPath);
if (!file) {
throw new Error(`File ${oldPath} does not exist`);
}
this.localFiles.set(newPath, file);
if (oldPath !== newPath) {
this.localFiles.delete(oldPath);
}
}
// Test operations
public async createFile(path: string, content: string): Promise<void> {
this.log(`Creating file ${path} with content: ${content}`);
if (this.localFiles.has(path)) {
throw new Error(`File ${path} already exists`);
}
const contentBytes = new TextEncoder().encode(content);
this.localFiles.set(path, contentBytes);
// Only sync if enabled - otherwise scheduleSyncForOfflineChanges will pick it up
if (this.isSyncEnabled) {
await this.client.syncLocallyCreatedFile(path);
}
}
public async updateFile(path: string, content: string): Promise<void> {
this.log(`Updating file ${path} with content: ${content}`);
const contentBytes = new TextEncoder().encode(content);
this.localFiles.set(path, contentBytes);
// Only sync if enabled
if (this.isSyncEnabled) {
await this.client.syncLocallyUpdatedFile({ relativePath: path });
}
}
public async renameFile(oldPath: string, newPath: string): Promise<void> {
this.log(`Renaming file ${oldPath} to ${newPath}`);
// Update local state
const file = this.localFiles.get(oldPath);
if (!file) {
throw new Error(`File ${oldPath} does not exist`);
}
this.localFiles.set(newPath, file);
if (oldPath !== newPath) {
this.localFiles.delete(oldPath);
}
// Only sync if enabled
if (this.isSyncEnabled) {
await this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
}
public async deleteFile(path: string): Promise<void> {
this.log(`Deleting file ${path}`);
// Update local state
this.localFiles.delete(path);
// Only sync if enabled
if (this.isSyncEnabled) {
await this.client.syncLocallyDeletedFile(path);
}
}
public async waitForSync(): Promise<void> {
this.log("Waiting for sync to complete...");
await this.client.waitUntilFinished();
this.log("Sync complete");
}
public async disableSync(): Promise<void> {
this.log("Disabling sync");
this.isSyncEnabled = false;
await this.client.setSetting("isSyncEnabled", false);
}
public async enableSync(): Promise<void> {
this.log("Enabling sync");
this.isSyncEnabled = true;
await this.client.setSetting("isSyncEnabled", true);
}
public async assertContent(
path: string,
expectedContent: string
): Promise<void> {
this.log(`Asserting content of ${path} equals "${expectedContent}"`);
const exists = await this.exists(path);
assert(
exists,
`File ${path} does not exist on client ${this.clientId}`
);
const actualBytes = await this.read(path);
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();
}
public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path);
return new TextDecoder().decode(bytes);
}
public async cleanup(): Promise<void> {
this.log("Cleaning up...");
await this.client.waitUntilFinished();
await this.client.destroy();
this.log("Cleanup complete");
}
private log(message: string): void {
this.logger(`[Client ${this.clientId}] ${message}`);
}
}

View file

@ -0,0 +1,148 @@
import { spawn, type ChildProcess } from "node:child_process";
import { sleep } from "./utils/sleep";
export class ServerControl {
private process: ChildProcess | null = null;
private readonly serverPath: string;
private readonly configPath: string;
public constructor(serverPath: string, configPath: string) {
this.serverPath = serverPath;
this.configPath = configPath;
}
public async start(): Promise<void> {
if (this.process !== null) {
throw new Error("Server is already running");
}
console.log(`Starting server: ${this.serverPath} ${this.configPath}`);
let startupError: string | null = null;
this.process = spawn(this.serverPath, [this.configPath], {
stdio: ["ignore", "pipe", "pipe"],
detached: false
});
this.process.stdout?.on("data", (data: Buffer) => {
console.log(`[SERVER] ${data.toString().trim()}`);
});
this.process.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim();
console.error(`[SERVER ERROR] ${msg}`);
// Capture startup errors
if (msg.includes("Failed to") || msg.includes("Error")) {
startupError = msg;
}
});
this.process.on("error", (err) => {
console.error("[SERVER] Process error:", err);
startupError = err.message;
});
this.process.on("exit", (code, signal) => {
console.log(`[SERVER] Exited with code ${code}, signal ${signal}`);
this.process = null;
});
// Give the process a moment to fail if it's going to
await sleep(100);
// Check if process died during startup (exit handler sets this.process to null)
this.checkProcessAlive(startupError, "startup");
// Wait for server to be ready
await this.waitForReady();
// Final check that our process is still the one running
this.checkProcessAlive(startupError, "after startup");
}
public async waitForReady(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await fetch(
"http://localhost:3000/vaults/test/ping"
);
if (response.ok) {
console.log("[SERVER] Ready");
return;
}
} catch {
// Server not ready yet
}
await sleep(100);
}
throw new Error("Server failed to start within timeout");
}
public pause(): void {
if (this.process?.pid === undefined) {
throw new Error("Server is not running");
}
console.log("[SERVER] Pausing...");
process.kill(this.process.pid, "SIGSTOP");
}
public resume(): void {
if (this.process?.pid === undefined) {
throw new Error("Server is not running");
}
console.log("[SERVER] Resuming...");
process.kill(this.process.pid, "SIGCONT");
}
public async stop(): Promise<void> {
if (this.process?.pid === undefined) {
return;
}
console.log("[SERVER] Stopping...");
const { pid } = this.process;
return new Promise((resolve) => {
if (this.process === null) {
resolve();
return;
}
this.process.on("exit", () => {
resolve();
});
// Try graceful shutdown first
process.kill(pid, "SIGTERM");
// Force kill after 5 seconds
setTimeout(() => {
if (this.process?.pid !== undefined) {
process.kill(this.process.pid, "SIGKILL");
}
}, 5000);
});
}
public isRunning(): boolean {
return this.process?.pid !== undefined;
}
private checkProcessAlive(
startupError: string | null,
phase: string
): void {
const proc = this.process;
if (proc === null) {
throw new Error(
`Server process died during ${phase}: ${startupError ?? "unknown error"}`
);
}
if (proc.exitCode !== null) {
throw new Error(
`Server process exited during ${phase}: ${startupError ?? "unknown error"}`
);
}
}
}

View file

@ -0,0 +1,35 @@
/**
* Deterministic test framework for VaultLink sync testing.
* Allows defining exact sequences of operations to test specific scenarios.
*/
export type TestStep =
| { type: "create"; client: number; path: string; content: string }
| { type: "update"; client: number; path: string; content: string }
| { type: "rename"; client: number; oldPath: string; newPath: string }
| { type: "delete"; client: number; path: string }
| { type: "sync"; client?: number } // wait for sync (specific client or all if undefined)
| { type: "disable-sync"; client: number }
| { type: "enable-sync"; client: number }
| { type: "wait"; duration: number } // wait N milliseconds
| { type: "pause-server" }
| { type: "resume-server" }
| { type: "barrier" } // wait for all clients to finish pending operations
| { 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" }; // all clients have same files and content
export interface TestDefinition {
name: string;
description?: string;
clients: number;
steps: TestStep[];
}
export interface TestResult {
success: boolean;
error?: string;
stepsFailed?: number;
duration: number;
}

View file

@ -0,0 +1,292 @@
import type { TestDefinition, TestResult, TestStep } from "./test-definition";
import { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control";
import type { SyncSettings } from "sync-client";
import { utils } from "sync-client";
import { sleep } from "./utils/sleep";
import { assert } from "./utils/assert";
import WebSocket from "ws";
import { randomUUID } from "node:crypto";
export class TestRunner {
private agents: DeterministicAgent[] = [];
private readonly serverControl: ServerControl;
private readonly token: string;
private readonly remoteUri: string;
private readonly logBuffer: string[] = [];
public constructor(
serverControl: ServerControl,
options: {
token?: string;
remoteUri?: string;
} = {}
) {
this.serverControl = serverControl;
this.token = options.token ?? "test-token-change-me ";
this.remoteUri = options.remoteUri ?? "http://localhost:3000";
}
public async runTest(test: TestDefinition): Promise<TestResult> {
const startTime = Date.now();
this.log(`\n${"=".repeat(80)}`);
this.log(`Running test: ${test.name}`);
if (test.description !== undefined && test.description !== "") {
this.log(`Description: ${test.description}`);
}
this.log(`Clients: ${test.clients}`);
this.log(`Steps: ${test.steps.length}`);
this.log("=".repeat(80));
try {
// Initialize agents
await this.initializeAgents(test.clients);
// Execute steps
for (let i = 0; i < test.steps.length; i++) {
const step = test.steps[i];
this.log(
`\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
);
await this.executeStep(step);
}
// Cleanup
await this.cleanup();
const duration = Date.now() - startTime;
this.log(`\n✓ Test passed: ${test.name} (${duration}ms)`);
return {
success: true,
duration
};
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage =
error instanceof Error ? error.message : String(error);
this.log(`\n✗ Test failed: ${test.name}`);
this.log(`Error: ${errorMessage}`);
await this.cleanup();
return {
success: false,
error: errorMessage,
duration
};
}
}
public getLog(): string {
return this.logBuffer.join("\n");
}
private log(message: string): void {
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] ${message}`;
console.log(logLine);
this.logBuffer.push(logLine);
}
private async initializeAgents(count: number): Promise<void> {
// Use unique vault name for each test run to avoid data interference
const vaultName = `test-${randomUUID()}`;
this.log(`\nInitializing ${count} agents with vault: ${vaultName}`);
const settings: Partial<SyncSettings> = {
// Start with sync disabled to avoid scheduleSyncForOfflineChanges running
// before we've created our test files. Tests must explicitly enable sync.
isSyncEnabled: false,
token: this.token,
vaultName,
syncConcurrency: 1,
remoteUri: this.remoteUri
};
for (let i = 0; i < count; i++) {
const agent = new DeterministicAgent(i, settings, (msg) => {
this.log(msg);
});
// WebSocket from 'ws' package needs type assertion for browser WebSocket interface
await agent.init(
fetch,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
WebSocket as unknown as typeof globalThis.WebSocket
);
this.agents.push(agent);
this.log(`Initialized client ${i}`);
}
// Wait for WebSocket connections to fully establish
await sleep(100);
this.log("All agents initialized and connected");
// Note: Sync is disabled on all agents. Tests must explicitly enable sync.
}
private async executeStep(step: TestStep): Promise<void> {
switch (step.type) {
case "create":
await this.agents[step.client].createFile(
step.path,
step.content
);
break;
case "update":
await this.agents[step.client].updateFile(
step.path,
step.content
);
break;
case "rename":
await this.agents[step.client].renameFile(
step.oldPath,
step.newPath
);
break;
case "delete":
await this.agents[step.client].deleteFile(step.path);
break;
case "sync":
if (step.client !== undefined) {
await this.agents[step.client].waitForSync();
} else {
// Wait for all clients
for (const agent of this.agents) {
await agent.waitForSync();
}
}
break;
case "disable-sync":
await this.agents[step.client].disableSync();
break;
case "enable-sync":
await this.agents[step.client].enableSync();
break;
case "wait":
this.log(`Waiting ${step.duration}ms...`);
await sleep(step.duration);
break;
case "pause-server":
this.serverControl.pause();
break;
case "resume-server":
this.serverControl.resume();
break;
case "barrier":
this.log(
"Barrier: waiting for all clients to finish pending operations..."
);
// First, wait for all local pending operations to complete
for (const agent of this.agents) {
await agent.waitForSync();
}
// Wait for network propagation
await sleep(500);
// Then sync again to ensure all clients have received updates from others
for (const agent of this.agents) {
await agent.waitForSync();
}
this.log("Barrier complete");
break;
case "assert-content":
await this.agents[step.client].assertContent(
step.path,
step.content
);
break;
case "assert-exists":
await this.agents[step.client].assertExists(step.path);
break;
case "assert-not-exists":
await this.agents[step.client].assertNotExists(step.path);
break;
case "assert-consistent":
await this.assertConsistent();
break;
default: {
const unknownStep = step as { type: string };
throw new Error(`Unknown step type: ${unknownStep.type}`);
}
}
}
private async assertConsistent(): Promise<void> {
this.log("Asserting all clients are consistent...");
if (this.agents.length < 2) {
this.log("Only one client, skipping consistency check");
return;
}
const [referenceAgent] = this.agents;
const referenceFiles = (await referenceAgent.getFiles()).sort();
this.log(
`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();
this.log(
`Client ${i} has ${files.length} files: ${files.join(", ")}`
);
// Check file lists match
assert(
files.length === referenceFiles.length,
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files`
);
for (let j = 0; j < files.length; j++) {
assert(
files[j] === referenceFiles[j],
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"`
);
}
// Check file contents match
for (const file of referenceFiles) {
const referenceContent =
await referenceAgent.getFileContent(file);
const agentContent = await agent.getFileContent(file);
assert(
referenceContent === agentContent,
`Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"`
);
}
}
this.log("✓ All clients are consistent");
}
private async cleanup(): Promise<void> {
this.log("\nCleaning up agents...");
for (const agent of this.agents) {
await agent.cleanup();
}
this.agents = [];
this.log("Cleanup complete");
}
}

View file

@ -0,0 +1,68 @@
import type { TestDefinition } from "../test-definition";
/**
* Rename-Create Conflict Test
*
* Scenario:
* - Client 0 creates file A with content "hi" and syncs it
* - Client 1 syncs (now has A with "hi")
* - Client 0 disables sync (disconnects WebSocket)
* - Client 1 renames A to B and syncs
* - Client 0 (offline, unaware of the rename) creates file B with content "hi"
* - Client 0 enables sync again
* - Both clients sync
*
* Expected behavior:
* - The system must resolve the conflict deterministically
* - Client 0's create of B conflicts with Client 1's rename of A to B
* - Possible resolutions:
* 1. One file wins (B contains one version)
* 2. Files are merged/renamed to avoid collision
* 3. One operation is rejected
* - Both clients must converge to a consistent state
*/
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.",
clients: 2,
steps: [
// Enable sync on all clients first (agents start with sync disabled)
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Client 0 creates file A with "hi" and syncs
{ type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "sync", client: 0 },
// Client 1 syncs to get file A
{ type: "sync", client: 1 },
{ type: "assert-exists", client: 1, path: "A.md" },
{ type: "assert-content", client: 1, path: "A.md", content: "hi" },
// IMPORTANT: Disable sync on Client 0 BEFORE Client 1 renames
// This ensures Client 0 doesn't receive the rename notification via WebSocket
{ type: "disable-sync", client: 0 },
// Client 1 renames A to B and syncs
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 },
// Client 0 creates B (without knowing about the rename, since sync is disabled)
{ type: "create", client: 0, path: "B.md", content: "hi" },
// Now enable sync on Client 0 and let conflict resolution happen
{ type: "enable-sync", client: 0 },
{ type: "barrier" }, // Wait for conflict resolution
// Give system time to propagate
{ type: "wait", duration: 500 },
{ type: "barrier" },
// Verify both clients converge to the same state
{ type: "assert-consistent" }
]
};

View file

@ -0,0 +1,46 @@
import type { TestDefinition } from "../test-definition";
/**
* Write/Write Conflict Test
*
* Scenario:
* - Client 0 creates file A with content "hello"
* - Client 1 creates file A with content "world"
* - Both clients sync
* - The system must resolve the conflict deterministically
*
* Expected behavior:
* - One version wins (typically last-write-wins or version-based)
* - Both clients converge to the same final state
*/
export const writeWriteConflictTest: TestDefinition = {
name: "Write/Write Conflict",
description:
"Two clients simultaneously create the same file with different content. " +
"The system should resolve the conflict and both clients should converge.",
clients: 2,
steps: [
// Both clients go offline
{ type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
// Both clients create the same file with different content
{ type: "create", client: 0, path: "A.md", content: "hello" },
{ type: "create", client: 1, path: "A.md", content: "world" },
// Enable sync and wait for conflict resolution
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
// Wait for sync to complete and propagate
{ type: "barrier" },
// Extra time for any conflict resolution
{ type: "wait", duration: 300 },
{ type: "barrier" },
// Verify both clients have the same file(s) and content
{ type: "assert-consistent" }
]
};

View file

@ -0,0 +1,5 @@
export function assert(value: boolean, message: string): asserts value {
if (!value) {
throw new Error(message);
}
}

View file

@ -0,0 +1,3 @@
export async function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}