diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 6e68e727..1a052319 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -5,229 +5,101 @@ 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 { TOKEN, REMOTE_URI, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts"; import * as path from "node:path"; import * as fs from "node:fs"; +import { debugging, Logger } from "sync-client"; -// Global error handlers to catch unhandled errors -process.on("unhandledRejection", (reason, promise) => { - console.error("Unhandled Rejection at:", promise); - console.error("Reason:", reason); +const logger = new Logger(); +debugging.logToConsole(logger); + +process.on("unhandledRejection", (reason) => { + logger.error(`Unhandled Rejection: ${reason}`); process.exit(1); }); process.on("uncaughtException", (error) => { - console.error("Uncaught Exception:", error); + logger.error(`Uncaught Exception: ${error}`); process.exit(1); }); -// Available tests - using Partial to allow undefined lookup const TESTS: Partial> = { "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 Run specific test (or "all") - --list List available tests - --server Path to sync_server binary (default: auto-detect) - --config 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 { - const args = process.argv.slice(2); + const cwd = process.cwd(); + let projectRoot = cwd; - // 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); - } + if (cwd.endsWith("frontend/deterministic-tests")) { + projectRoot = path.resolve(cwd, "../.."); + } else if (cwd.endsWith("frontend")) { + projectRoot = path.resolve(cwd, ".."); } - // 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); - } + const serverPath = path.join(projectRoot, SERVER_BINARY_PATH); + if (!fs.existsSync(serverPath)) { + logger.error(`Server binary not found at: ${serverPath}`); + 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); - } + const configPath = path.join(projectRoot, CONFIG_PATH); + if (!fs.existsSync(configPath)) { + logger.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); - } + if (test) { 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`); + logger.info(`Server: ${serverPath}`); + logger.info(`Config: ${configPath}`); + logger.info(`Tests to run: ${testsToRun.length}`); - // Initialize server control - const serverControl = new ServerControl(serverPath, configPath); + const serverControl = new ServerControl(serverPath, configPath, logger); 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(); - } + await serverControl.start(); + await serverControl.waitForReady(); - // Run tests for (const test of testsToRun) { - const runner = new TestRunner(serverControl); + const runner = new TestRunner( + serverControl, + logger, + TOKEN, + REMOTE_URI + ); const result = await runner.runTest(test); if (!result.success) { allPassed = false; - console.error(`\n✗ FAILED: ${test.name}`); - console.error(`Error: ${result.error}`); + logger.error(`\n✗ FAILED: ${test.name}`); + logger.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)); + logger.info(`\n✓ PASSED: ${test.name} (${result.duration}ms)`); } } } finally { - // Stop server if we're managing it - if (manageServer) { - await serverControl.stop(); - } + await serverControl.stop(); } - console.log(`\n${"=".repeat(80)}`); if (allPassed) { - console.log("✓ All tests passed!"); + logger.info("✓ All tests passed!"); process.exit(0); } else { - console.log("✗ Some tests failed"); + logger.info("✗ Some tests failed"); process.exit(1); } } main().catch((err: unknown) => { - console.error("Unexpected error:", err); + logger.error(`Unexpected error: ${err}`); process.exit(1); }); diff --git a/frontend/deterministic-tests/src/consts.ts b/frontend/deterministic-tests/src/consts.ts new file mode 100644 index 00000000..d3e957ce --- /dev/null +++ b/frontend/deterministic-tests/src/consts.ts @@ -0,0 +1,5 @@ +export const TOKEN = "test-token-change-me "; +export const REMOTE_URI = "http://localhost:3000"; +export const PING_URL = `${REMOTE_URI}/vaults/test/ping`; +export const SERVER_BINARY_PATH = "sync-server/target/debug/sync_server"; +export const CONFIG_PATH = "sync-server/config-e2e.yml"; diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 66933d36..7434cb30 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -1,28 +1,15 @@ -import type { StoredDatabase, TextWithCursors } from "sync-client"; -import type { - RelativePath, - FileSystemOperations, - SyncSettings -} from "sync-client"; -import { SyncClient } from "sync-client"; +import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client"; +import { SyncClient, debugging } 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 { +export class DeterministicAgent extends debugging.InMemoryFileSystem { public readonly clientId: number; private readonly logger: (msg: string) => void; - private readonly localFiles = new Map(); private client!: SyncClient; private data: Partial<{ settings: Partial; database: Partial; }> = {}; - // Track sync state locally to avoid calling sync methods when disabled private isSyncEnabled = true; public constructor( @@ -30,6 +17,7 @@ export class DeterministicAgent implements FileSystemOperations { initialSettings: Partial, logger: (msg: string) => void ) { + super(); this.clientId = clientId; this.logger = logger; this.data.settings = initialSettings; @@ -52,7 +40,6 @@ export class DeterministicAgent implements FileSystemOperations { await this.client.start(); - // Verify connection is working const connectionCheck = await this.client.checkConnection(); assert( connectionCheck.isSuccessful, @@ -60,87 +47,14 @@ export class DeterministicAgent implements FileSystemOperations { ); } - // FileSystemOperations implementation - public async listFilesRecursively( - _root?: RelativePath - ): Promise { - return Array.from(this.localFiles.keys()); - } - - public async read(path: RelativePath): Promise { - const file = this.localFiles.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - return file; - } - - public async getFileSize(path: RelativePath): Promise { - return (await this.read(path)).length; - } - - public async exists(path: RelativePath): Promise { - return this.localFiles.has(path); - } - - public async write(path: RelativePath, content: Uint8Array): Promise { - // 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 { - // Virtual FS doesn't need directories - } - - public async atomicUpdateText( - path: RelativePath, - updater: (currentContent: TextWithCursors) => TextWithCursors - ): Promise { - // 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 { - // 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 { - // 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 { this.log(`Creating file ${path} with content: ${content}`); - if (this.localFiles.has(path)) { + if (this.files.has(path)) { throw new Error(`File ${path} already exists`); } const contentBytes = new TextEncoder().encode(content); - this.localFiles.set(path, contentBytes); + this.files.set(path, contentBytes); - // Only sync if enabled - otherwise scheduleSyncForOfflineChanges will pick it up if (this.isSyncEnabled) { await this.client.syncLocallyCreatedFile(path); } @@ -149,9 +63,8 @@ export class DeterministicAgent implements FileSystemOperations { public async updateFile(path: string, content: string): Promise { this.log(`Updating file ${path} with content: ${content}`); const contentBytes = new TextEncoder().encode(content); - this.localFiles.set(path, contentBytes); + this.files.set(path, contentBytes); - // Only sync if enabled if (this.isSyncEnabled) { await this.client.syncLocallyUpdatedFile({ relativePath: path }); } @@ -159,16 +72,14 @@ export class DeterministicAgent implements FileSystemOperations { public async renameFile(oldPath: string, newPath: string): Promise { this.log(`Renaming file ${oldPath} to ${newPath}`); - // Update local state - const file = this.localFiles.get(oldPath); + const file = this.files.get(oldPath); if (!file) { throw new Error(`File ${oldPath} does not exist`); } - this.localFiles.set(newPath, file); + this.files.set(newPath, file); if (oldPath !== newPath) { - this.localFiles.delete(oldPath); + this.files.delete(oldPath); } - // Only sync if enabled if (this.isSyncEnabled) { await this.client.syncLocallyUpdatedFile({ oldPath, @@ -179,9 +90,7 @@ export class DeterministicAgent implements FileSystemOperations { public async deleteFile(path: string): Promise { this.log(`Deleting file ${path}`); - // Update local state - this.localFiles.delete(path); - // Only sync if enabled + this.files.delete(path); if (this.isSyncEnabled) { await this.client.syncLocallyDeletedFile(path); } diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index 9c15b7e8..8d6a00ea 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -1,14 +1,18 @@ import { spawn, type ChildProcess } from "node:child_process"; import { sleep } from "./utils/sleep"; +import type { Logger } from "sync-client"; +import { PING_URL } from "./consts"; export class ServerControl { private process: ChildProcess | null = null; private readonly serverPath: string; private readonly configPath: string; + private readonly logger: Logger; - public constructor(serverPath: string, configPath: string) { + public constructor(serverPath: string, configPath: string, logger: Logger) { this.serverPath = serverPath; this.configPath = configPath; + this.logger = logger; } public async start(): Promise { @@ -16,7 +20,9 @@ export class ServerControl { throw new Error("Server is already running"); } - console.log(`Starting server: ${this.serverPath} ${this.configPath}`); + this.logger.info( + `Starting server: ${this.serverPath} ${this.configPath}` + ); let startupError: string | null = null; @@ -26,53 +32,45 @@ export class ServerControl { }); this.process.stdout?.on("data", (data: Buffer) => { - console.log(`[SERVER] ${data.toString().trim()}`); + this.logger.info(`[SERVER] ${data.toString().trim()}`); }); this.process.stderr?.on("data", (data: Buffer) => { const msg = data.toString().trim(); - console.error(`[SERVER ERROR] ${msg}`); - // Capture startup errors + this.logger.error(`[SERVER ERROR] ${msg}`); if (msg.includes("Failed to") || msg.includes("Error")) { startupError = msg; } }); this.process.on("error", (err) => { - console.error("[SERVER] Process error:", err); + this.logger.error(`[SERVER] Process error: ${err.message}`); startupError = err.message; }); this.process.on("exit", (code, signal) => { - console.log(`[SERVER] Exited with code ${code}, signal ${signal}`); + this.logger.info( + `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 { for (let i = 0; i < maxAttempts; i++) { try { - const response = await fetch( - "http://localhost:3000/vaults/test/ping" - ); + const response = await fetch(PING_URL); if (response.ok) { - console.log("[SERVER] Ready"); + this.logger.info("[SERVER] Ready"); return; } } catch { - // Server not ready yet + // Server not ready yet, continue polling } await sleep(100); } @@ -83,7 +81,7 @@ export class ServerControl { if (this.process?.pid === undefined) { throw new Error("Server is not running"); } - console.log("[SERVER] Pausing..."); + this.logger.info("Server pausing..."); process.kill(this.process.pid, "SIGSTOP"); } @@ -91,7 +89,7 @@ export class ServerControl { if (this.process?.pid === undefined) { throw new Error("Server is not running"); } - console.log("[SERVER] Resuming..."); + this.logger.info("Server resuming..."); process.kill(this.process.pid, "SIGCONT"); } @@ -100,7 +98,7 @@ export class ServerControl { return; } - console.log("[SERVER] Stopping..."); + this.logger.info("Server stopping..."); const { pid } = this.process; return new Promise((resolve) => { @@ -113,10 +111,8 @@ export class ServerControl { 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"); diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts index 0968f3a4..b00ea7c9 100644 --- a/frontend/deterministic-tests/src/test-definition.ts +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -1,24 +1,22 @@ -/** - * Deterministic test framework for VaultLink sync testing. - * Allows defining exact sequences of operations to test specific scenarios. - */ +export interface ClientState { + files: Map; +} 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: "sync"; client?: number } | { 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: "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" }; // all clients have same files and content + | { type: "assert-consistent"; verify?: (state: ClientState) => void }; export interface TestDefinition { name: string; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index 570c7de1..eb778c1a 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -1,9 +1,12 @@ -import type { TestDefinition, TestResult, TestStep } from "./test-definition"; +import type { + TestDefinition, + TestResult, + TestStep, + ClientState +} 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 type { SyncSettings, Logger } from "sync-client"; import { assert } from "./utils/assert"; import WebSocket from "ws"; import { randomUUID } from "node:crypto"; @@ -13,30 +16,28 @@ export class TestRunner { private readonly serverControl: ServerControl; private readonly token: string; private readonly remoteUri: string; - private readonly logBuffer: string[] = []; + private readonly logger: Logger; public constructor( serverControl: ServerControl, - options: { - token?: string; - remoteUri?: string; - } = {} + logger: Logger, + token: string, + remoteUri: string ) { this.serverControl = serverControl; - this.token = options.token ?? "test-token-change-me "; - this.remoteUri = options.remoteUri ?? "http://localhost:3000"; + this.logger = logger; + this.token = token; + this.remoteUri = remoteUri; } public async runTest(test: TestDefinition): Promise { const startTime = Date.now(); - this.log(`\n${"=".repeat(80)}`); - this.log(`Running test: ${test.name}`); + this.logger.info(`Running test: ${test.name}`); if (test.description !== undefined && test.description !== "") { - this.log(`Description: ${test.description}`); + this.logger.info(`Description: ${test.description}`); } - this.log(`Clients: ${test.clients}`); - this.log(`Steps: ${test.steps.length}`); - this.log("=".repeat(80)); + this.logger.info(`Clients: ${test.clients}`); + this.logger.info(`Steps: ${test.steps.length}`); try { // Initialize agents @@ -45,7 +46,7 @@ export class TestRunner { // Execute steps for (let i = 0; i < test.steps.length; i++) { const step = test.steps[i]; - this.log( + this.logger.info( `\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}` ); await this.executeStep(step); @@ -55,7 +56,7 @@ export class TestRunner { await this.cleanup(); const duration = Date.now() - startTime; - this.log(`\n✓ Test passed: ${test.name} (${duration}ms)`); + this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`); return { success: true, @@ -65,8 +66,8 @@ export class TestRunner { 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}`); + this.logger.info(`\n✗ Test failed: ${test.name}`); + this.logger.info(`Error: ${errorMessage}`); await this.cleanup(); @@ -78,25 +79,13 @@ export class TestRunner { } } - 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 { - // Use unique vault name for each test run to avoid data interference const vaultName = `test-${randomUUID()}`; - this.log(`\nInitializing ${count} agents with vault: ${vaultName}`); + this.logger.info( + `Initializing ${count} agents with vault: ${vaultName}` + ); const settings: Partial = { - // 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, @@ -106,9 +95,8 @@ export class TestRunner { for (let i = 0; i < count; i++) { const agent = new DeterministicAgent(i, settings, (msg) => { - this.log(msg); + this.logger.info(msg); }); - // WebSocket from 'ws' package needs type assertion for browser WebSocket interface await agent.init( fetch, @@ -116,13 +104,10 @@ export class TestRunner { WebSocket as unknown as typeof globalThis.WebSocket ); this.agents.push(agent); - this.log(`Initialized client ${i}`); + this.logger.info(`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. + this.logger.info("All agents initialized"); } private async executeStep(step: TestStep): Promise { @@ -156,7 +141,6 @@ export class TestRunner { if (step.client !== undefined) { await this.agents[step.client].waitForSync(); } else { - // Wait for all clients for (const agent of this.agents) { await agent.waitForSync(); } @@ -171,11 +155,6 @@ export class TestRunner { 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; @@ -185,22 +164,7 @@ export class TestRunner { 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"); + await this.waitForConvergence(); break; case "assert-content": @@ -219,7 +183,7 @@ export class TestRunner { break; case "assert-consistent": - await this.assertConsistent(); + await this.assertConsistent(step.verify); break; default: { @@ -229,18 +193,80 @@ export class TestRunner { } } - private async assertConsistent(): Promise { - this.log("Asserting all clients are consistent..."); + private async waitForConvergence(maxAttempts = 50): Promise { + this.logger.info("Barrier: waiting for convergence..."); + for (let attempt = 0; attempt < maxAttempts; attempt++) { + for (const agent of this.agents) { + await agent.waitForSync(); + } + + if (await this.checkConsistency()) { + this.logger.info("Barrier complete: all clients converged"); + return; + } + + this.logger.info( + `Convergence attempt ${attempt + 1}/${maxAttempts}: not yet consistent, syncing again...` + ); + } + + throw new Error( + `Clients did not converge after ${maxAttempts} attempts` + ); + } + + private async checkConsistency(): Promise { if (this.agents.length < 2) { - this.log("Only one client, skipping consistency check"); - return; + return true; } const [referenceAgent] = this.agents; const referenceFiles = (await referenceAgent.getFiles()).sort(); - this.log( + for (let i = 1; i < this.agents.length; i++) { + const agent = this.agents[i]; + const files = (await agent.getFiles()).sort(); + + if (files.length !== referenceFiles.length) { + return false; + } + + for (let j = 0; j < files.length; j++) { + if (files[j] !== referenceFiles[j]) { + return false; + } + } + + for (const file of referenceFiles) { + const referenceContent = + await referenceAgent.getFileContent(file); + const agentContent = await agent.getFileContent(file); + + if (referenceContent !== agentContent) { + return false; + } + } + } + + return true; + } + + private async assertConsistent( + verify?: (state: ClientState) => void + ): Promise { + this.logger.info("Asserting all clients are consistent..."); + + 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); + } + + this.logger.info( `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` ); @@ -248,11 +274,10 @@ export class TestRunner { const agent = this.agents[i]; const files = (await agent.getFiles()).sort(); - this.log( + this.logger.info( `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` @@ -265,10 +290,8 @@ export class TestRunner { ); } - // Check file contents match for (const file of referenceFiles) { - const referenceContent = - await referenceAgent.getFileContent(file); + const referenceContent = referenceState.files.get(file); const agentContent = await agent.getFileContent(file); assert( @@ -278,15 +301,21 @@ export class TestRunner { } } - this.log("✓ All clients are consistent"); + this.logger.info("✓ All clients are consistent"); + + if (verify) { + this.logger.info("Running custom verification..."); + verify(referenceState); + this.logger.info("✓ Custom verification passed"); + } } private async cleanup(): Promise { - this.log("\nCleaning up agents..."); + this.logger.info("\nCleaning up agents..."); for (const agent of this.agents) { await agent.cleanup(); } this.agents = []; - this.log("Cleanup complete"); + this.logger.info("Cleanup complete"); } } diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index ecb33ec6..47660ac4 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -1,26 +1,5 @@ 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: @@ -28,41 +7,19 @@ export const renameCreateConflictTest: TestDefinition = { "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" } ] }; diff --git a/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts index c5d0ddd0..b3ea5859 100644 --- a/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts @@ -1,18 +1,16 @@ -import type { TestDefinition } from "../test-definition"; +import type { ClientState, TestDefinition } from "../test-definition"; +import { assert } from "../utils/assert"; + +function verifyMergedContent(state: ClientState): void { + assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`); + assert(state.files.has("A.md"), "Expected A.md to exist"); + const content = state.files.get("A.md") ?? ""; + assert( + content.includes("hello") && content.includes("world"), + `Expected A.md to contain both "hello" and "world", got: "${content}"` + ); +} -/** - * 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: @@ -20,27 +18,13 @@ export const writeWriteConflictTest: TestDefinition = { "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" } + { type: "assert-consistent", verify: verifyMergedContent } ] }; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 1e33ac41..eda922ed 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -17,6 +17,7 @@ export default [ }, extends: [eslint.configs.recommended, tseslint.configs.all], rules: { + "no-console": "error", "no-unused-vars": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/no-unused-vars": "off", diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 48fd8954..ab1748bc 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import * as path from "path"; import * as fs from "fs/promises"; import * as fsSync from "fs"; @@ -65,7 +66,7 @@ async function main(): Promise { console.log( styleText("VaultLink Local CLI", "bold", "cyan") + - colorize(` v${packageJson.version}`, "dim") + colorize(` v${packageJson.version}`, "dim") ); console.log(colorize("=".repeat(50), "dim")); console.log( diff --git a/frontend/local-client-cli/src/healthcheck.ts b/frontend/local-client-cli/src/healthcheck.ts index 2dd9e721..d7211c88 100644 --- a/frontend/local-client-cli/src/healthcheck.ts +++ b/frontend/local-client-cli/src/healthcheck.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable no-console */ /** * Healthcheck script for Docker container diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index d90da7bf..c4e4313d 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all"; import { logToConsole } from "./utils/debugging/log-to-console"; import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory"; import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"; +import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system"; import { getRandomColor } from "./utils/get-random-color"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; @@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text"; export const debugging = { slowFetchFactory, slowWebSocketFactory, - logToConsole + logToConsole, + InMemoryFileSystem }; export const utils = { diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index b6add795..64644416 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -74,12 +74,6 @@ export class UnrestrictedSyncer { force?: boolean; document: DocumentRecord; }): Promise { - // this.history.addHistoryEntry({ - // status: SyncStatus.SUCCESS, - // details: updateDetails, - // message: `Successfully uploaded locally created file` - // }); - const updateDetails: | SyncCreateDetails | SyncUpdateDetails @@ -221,15 +215,6 @@ export class UnrestrictedSyncer { relativePath: response.relativePath }; - // if (areThereLocalChanges) { - // this.history.addHistoryEntry({ - // status: SyncStatus.SUCCESS, - // details: actualUpdateDetails, - // message: `Successfully uploaded locally updated file to the server`, - // author: response.userId - // }); - // } else - if (!response.isDeleted) { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -246,7 +231,7 @@ export class UnrestrictedSyncer { relativePath: document.relativePath }, message: - "File has been deleted remotely, so we deleted it locally", + "Successfully deleted file which had been deleted remotely", author: response.userId, timestamp: new Date(response.updatedDate) }); diff --git a/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts b/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts new file mode 100644 index 00000000..d1cdac3b --- /dev/null +++ b/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts @@ -0,0 +1,70 @@ +import type { RelativePath } from "../../persistence/database"; +import type { TextWithCursors } from "reconcile-text"; +import type { FileSystemOperations } from "../../file-operations/filesystem-operations"; + +export class InMemoryFileSystem implements FileSystemOperations { + protected readonly files = new Map(); + + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { + return Array.from(this.files.keys()); + } + + public async read(path: RelativePath): Promise { + const file = this.files.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + return file; + } + + public async write(path: RelativePath, content: Uint8Array): Promise { + this.files.set(path, content); + } + + public async atomicUpdateText( + path: RelativePath, + updater: (current: TextWithCursors) => TextWithCursors + ): Promise { + const file = this.files.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.files.set(path, new TextEncoder().encode(newContent)); + return newContent; + } + + public async getFileSize(path: RelativePath): Promise { + return (await this.read(path)).length; + } + + public async exists(path: RelativePath): Promise { + return this.files.has(path); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public async createDirectory(_path: RelativePath): Promise { + // This doesn't mean anything in our virtual FS representation + } + + public async delete(path: RelativePath): Promise { + this.files.delete(path); + } + + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + const file = this.files.get(oldPath); + if (!file) { + throw new Error(`File ${oldPath} does not exist`); + } + this.files.set(newPath, file); + if (oldPath !== newPath) { + this.files.delete(oldPath); + } + } +} diff --git a/frontend/sync-client/src/utils/debugging/log-to-console.ts b/frontend/sync-client/src/utils/debugging/log-to-console.ts index 9fdca13b..329ddfb0 100644 --- a/frontend/sync-client/src/utils/debugging/log-to-console.ts +++ b/frontend/sync-client/src/utils/debugging/log-to-console.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import type { Logger, LogLine } from "../../tracing/logger"; import { LogLevel } from "../../tracing/logger"; diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 67368303..abf7da2c 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { choose } from "../utils/choose"; import { v4 as uuidv4 } from "uuid"; import { assert } from "../utils/assert"; @@ -94,22 +95,12 @@ export class MockAgent extends MockClient { } public async createInitialDocuments(count: number): Promise { - this.client.logger.info(`Creating ${count} initial documents`); - for (let i = 0; i < count; i++) { const file = `initial-${i}.md`; + this.doNotTouchWhileOffline.push(file); const content = this.getContent(); - this.client.logger.info( - `Creating initial file ${file} with content ${content}` - ); - await this.create(file, new TextEncoder().encode(` ${content} `), { - ignoreSlowFileEvents: true - }); + this.files.set(file, new TextEncoder().encode(` ${content} `)); } - - // Wait for all initial documents to sync - await this.client.waitUntilFinished(); - this.client.logger.info(`Initial documents created and synced`); } public async waitUntilSynced(): Promise { @@ -159,7 +150,7 @@ export class MockAgent extends MockClient { JSON.stringify(this.data, null, 2) ); this.client.logger.info( - JSON.stringify(this.localFiles, null, 2) + JSON.stringify(this.files, null, 2) ); throw error; } @@ -192,14 +183,14 @@ export class MockAgent extends MockClient { } public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { - const globalFiles = Array.from(otherAgent.localFiles.keys()); - const localFiles = Array.from(this.localFiles.keys()); + const globalFiles = Array.from(otherAgent.files.keys()); + const localFiles = Array.from(this.files.keys()); const missingInOther = localFiles.filter( - (file) => !otherAgent.localFiles.has(file) + (file) => !otherAgent.files.has(file) ); const missingInLocal = globalFiles.filter( - (file) => !this.localFiles.has(file) + (file) => !this.files.has(file) ); try { @@ -214,10 +205,10 @@ export class MockAgent extends MockClient { for (const file of globalFiles) { const localContent = new TextDecoder().decode( - this.localFiles.get(file) + this.files.get(file) ); const otherContent = new TextDecoder().decode( - otherAgent.localFiles.get(file) + otherAgent.files.get(file) ); assert( localContent === otherContent, @@ -229,15 +220,13 @@ export class MockAgent extends MockClient { "Local data: " + JSON.stringify(this.data, null, 2) ); this.client.logger.info( - "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") + "Local files: " + Array.from(otherAgent.files.keys()).join(", ") ); otherAgent.client.logger.info( "Local data: " + JSON.stringify(otherAgent.data, null, 2) ); otherAgent.client.logger.info( - "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") + "Local files: " + Array.from(otherAgent.files.keys()).join(", ") ); throw e; @@ -254,9 +243,9 @@ export class MockAgent extends MockClient { } for (const content of this.writtenContents) { - const found = Array.from(this.localFiles.keys()).filter((key) => { + const found = Array.from(this.files.keys()).filter((key) => { return new TextDecoder() - .decode(this.localFiles.get(key)) + .decode(this.files.get(key)) .includes(content); }); @@ -278,7 +267,7 @@ export class MockAgent extends MockClient { const [file] = found; const fileContent = new TextDecoder().decode( - this.localFiles.get(file) + this.files.get(file) ); assert( fileContent.split(content).length == 2, diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 9f8cc18a..c9f573e9 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -2,13 +2,12 @@ import type { StoredDatabase, TextWithCursors } from "sync-client"; import { assert } from "../utils/assert"; import { type RelativePath, - type FileSystemOperations, type SyncSettings, - SyncClient + SyncClient, + debugging } from "sync-client"; -export class MockClient implements FileSystemOperations { - protected readonly localFiles = new Map(); +export class MockClient extends debugging.InMemoryFileSystem { protected client!: SyncClient; protected data: Partial<{ @@ -20,6 +19,7 @@ export class MockClient implements FileSystemOperations { initialSettings: Partial, protected readonly useSlowFileEvents: boolean ) { + super(); this.data.settings = initialSettings; } @@ -40,28 +40,6 @@ export class MockClient implements FileSystemOperations { await this.client.start(); } - public async listFilesRecursively( - _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests - ): Promise { - return Array.from(this.localFiles.keys()); - } - - public async read(path: RelativePath): Promise { - const file = this.localFiles.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - return file; - } - - public async getFileSize(path: RelativePath): Promise { - return (await this.read(path)).length; - } - - public async exists(path: RelativePath): Promise { - return this.localFiles.has(path); - } - public async create( path: RelativePath, newContent: Uint8Array, @@ -69,13 +47,13 @@ export class MockClient implements FileSystemOperations { ignoreSlowFileEvents: false } ): Promise { - if (this.localFiles.has(path)) { + if (this.files.has(path)) { throw new Error(`File ${path} already exists`); } this.client.logger.info( `Creating file ${path} with content ${new TextDecoder().decode(newContent)}` ); - this.localFiles.set(path, newContent); + this.files.set(path, newContent); this.executeFileOperation( async () => this.client.syncLocallyCreatedFile(path), @@ -83,25 +61,21 @@ export class MockClient implements FileSystemOperations { ); } - public async createDirectory(_path: RelativePath): Promise { - // This doesn't mean anything in our virtual FS representation - } - - public async atomicUpdateText( + public override async atomicUpdateText( path: RelativePath, updater: (currentContent: TextWithCursors) => TextWithCursors, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } ): Promise { - const file = this.localFiles.get(path); + const file = this.files.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; const newContentUint8Array = new TextEncoder().encode(newContent); - this.localFiles.set(path, newContentUint8Array); + this.files.set(path, newContentUint8Array); if (!this.useSlowFileEvents) { const existingParts = currentContent @@ -109,13 +83,13 @@ export class MockClient implements FileSystemOperations { .map((part) => part.trim()); const newParts = newContent.split(" ").map((part) => part.trim()); existingParts.forEach((part) => - // all changes should be additive - { - assert( - newParts.includes(part), - `Part ${part} not found in new content: ${newContent}` - ); - } + // all changes should be additive + { + assert( + newParts.includes(part), + `Part ${part} not found in new content: ${newContent}` + ); + } ); } @@ -134,9 +108,12 @@ export class MockClient implements FileSystemOperations { return newContent; } - public async write(path: RelativePath, content: Uint8Array): Promise { - const hasExisted = this.localFiles.has(path); - this.localFiles.set(path, content); + public override async write( + path: RelativePath, + content: Uint8Array + ): Promise { + const hasExisted = this.files.has(path); + this.files.set(path, content); this.client.logger.info( `Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` @@ -153,16 +130,16 @@ export class MockClient implements FileSystemOperations { }); } - public async delete( + public override async delete( path: RelativePath, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } ): Promise { this.client.logger.info( - `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` + `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.files.get(path))}` ); - this.localFiles.delete(path); + this.files.delete(path); this.executeFileOperation( async () => this.client.syncLocallyDeletedFile(path), @@ -170,20 +147,20 @@ export class MockClient implements FileSystemOperations { ); } - public async rename( + public override async rename( oldPath: RelativePath, newPath: RelativePath, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } ): Promise { - const file = this.localFiles.get(oldPath); + const file = this.files.get(oldPath); if (!file) { throw new Error(`File ${oldPath} does not exist`); } - this.localFiles.set(newPath, file); + this.files.set(newPath, file); if (oldPath !== newPath) { - this.localFiles.delete(oldPath); + this.files.delete(oldPath); } this.client.logger.info( diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 97484d51..1f90743e 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import { randomCasing } from "./utils/random-casing"; const TEST_ITERATIONS = 5; -const MAX_INITIAL_DOCS = 5; +const MAX_INITIAL_DOCS = 0; // Simulate async file access by injecting waiting time before returning from file operations. let slowFileEvents = false; @@ -65,8 +65,6 @@ async function runTest({ } try { - await utils.awaitAll(clients.map(async (client) => client.init())); - for (const client of clients) { const initialDocCount = Math.floor( Math.random() * MAX_INITIAL_DOCS @@ -79,6 +77,10 @@ async function runTest({ } } + await utils.awaitAll(clients.map(async (client) => client.init())); + + + for (let i = 0; i < iterations; i++) { logger.info(`Iteration ${i + 1}/${iterations}`); await utils.awaitAll(clients.map(async (client) => client.act())); @@ -217,5 +219,8 @@ runTests() }) .catch((error: unknown) => { logger.error(`Error - tests failed with ${error}`); + if (error instanceof Error && error.stack) { + logger.error(error.stack); + } process.exit(1); }); diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index e9d47559..1f235b01 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -9,24 +9,24 @@ server: max_clients_per_vault: 256 response_timeout: 30m mergeable_file_extensions: - - md - - txt + - md + - txt users: user_configs: - - name: admin - token: test-token-change-me - vault_access: - type: allow_access_to_all - - name: other-admin - token: test-token-change-me2 - vault_access: - type: allow_access_to_all - - name: test - token: other-test-token - vault_access: - type: allow_list - allowed: - - default + - name: admin + token: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default logging: log_directory: logs log_rotation: 7days