Refactor tests

This commit is contained in:
Andras Schmelczer 2026-01-18 13:46:59 +00:00
commit f53ac121e8
19 changed files with 353 additions and 571 deletions

View file

@ -5,229 +5,101 @@ import { ServerControl } from "./server-control";
import type { TestDefinition } from "./test-definition"; import type { TestDefinition } from "./test-definition";
import { writeWriteConflictTest } from "./tests/write-write-conflict.test"; import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
import { renameCreateConflictTest } from "./tests/rename-create-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 path from "node:path";
import * as fs from "node:fs"; import * as fs from "node:fs";
import { debugging, Logger } from "sync-client";
// Global error handlers to catch unhandled errors const logger = new Logger();
process.on("unhandledRejection", (reason, promise) => { debugging.logToConsole(logger);
console.error("Unhandled Rejection at:", promise);
console.error("Reason:", reason); process.on("unhandledRejection", (reason) => {
logger.error(`Unhandled Rejection: ${reason}`);
process.exit(1); process.exit(1);
}); });
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error); logger.error(`Uncaught Exception: ${error}`);
process.exit(1); process.exit(1);
}); });
// Available tests - using Partial to allow undefined lookup
const TESTS: Partial<Record<string, TestDefinition>> = { const TESTS: Partial<Record<string, TestDefinition>> = {
"write-write-conflict": writeWriteConflictTest, "write-write-conflict": writeWriteConflictTest,
"rename-create-conflict": renameCreateConflictTest "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> { async function main(): Promise<void> {
const args = process.argv.slice(2); const cwd = process.cwd();
let projectRoot = cwd;
// Parse arguments if (cwd.endsWith("frontend/deterministic-tests")) {
let testName: string | undefined = undefined; projectRoot = path.resolve(cwd, "../..");
let serverPath: string | undefined = undefined; } else if (cwd.endsWith("frontend")) {
let configPath: string | undefined = undefined; projectRoot = path.resolve(cwd, "..");
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 const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
if (serverPath === undefined) { if (!fs.existsSync(serverPath)) {
// Try to find project root from current working directory logger.error(`Server binary not found at: ${serverPath}`);
const cwd = process.cwd(); process.exit(1);
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 configPath = path.join(projectRoot, CONFIG_PATH);
const cwd = process.cwd(); if (!fs.existsSync(configPath)) {
let projectRoot = cwd; logger.error(`Config file not found at: ${configPath}`);
process.exit(1);
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[] = []; const testsToRun: TestDefinition[] = [];
// Collect all defined tests
const allTests: TestDefinition[] = [];
for (const test of Object.values(TESTS)) { for (const test of Object.values(TESTS)) {
if (test !== undefined) { if (test) {
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); testsToRun.push(test);
} }
} else {
// Default: run all tests
testsToRun.push(...allTests);
} }
console.log(`\nDeterministic Test Suite`); logger.info(`Server: ${serverPath}`);
console.log("=".repeat(80)); logger.info(`Config: ${configPath}`);
console.log(`Server: ${serverPath}`); logger.info(`Tests to run: ${testsToRun.length}`);
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, logger);
const serverControl = new ServerControl(serverPath, configPath);
let allPassed = true; let allPassed = true;
try { try {
// Start server if we're managing it await serverControl.start();
if (manageServer) { await serverControl.waitForReady();
await serverControl.start();
} else {
console.log("Assuming server is already running...");
await serverControl.waitForReady();
}
// Run tests
for (const test of testsToRun) { 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); const result = await runner.runTest(test);
if (!result.success) { if (!result.success) {
allPassed = false; allPassed = false;
console.error(`\n✗ FAILED: ${test.name}`); logger.error(`\n✗ FAILED: ${test.name}`);
console.error(`Error: ${result.error}`); logger.error(`Error: ${result.error}`);
} else { } else {
console.log(`\n✓ PASSED: ${test.name} (${result.duration}ms)`); logger.info(`\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 { } finally {
// Stop server if we're managing it await serverControl.stop();
if (manageServer) {
await serverControl.stop();
}
} }
console.log(`\n${"=".repeat(80)}`);
if (allPassed) { if (allPassed) {
console.log("✓ All tests passed!"); logger.info("✓ All tests passed!");
process.exit(0); process.exit(0);
} else { } else {
console.log("✗ Some tests failed"); logger.info("✗ Some tests failed");
process.exit(1); process.exit(1);
} }
} }
main().catch((err: unknown) => { main().catch((err: unknown) => {
console.error("Unexpected error:", err); logger.error(`Unexpected error: ${err}`);
process.exit(1); process.exit(1);
}); });

View file

@ -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";

View file

@ -1,28 +1,15 @@
import type { StoredDatabase, TextWithCursors } from "sync-client"; import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client";
import type { import { SyncClient, debugging } from "sync-client";
RelativePath,
FileSystemOperations,
SyncSettings
} from "sync-client";
import { SyncClient } from "sync-client";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
/** export class DeterministicAgent extends debugging.InMemoryFileSystem {
* 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; public readonly clientId: number;
private readonly logger: (msg: string) => void; private readonly logger: (msg: string) => void;
private readonly localFiles = new Map<string, Uint8Array>();
private client!: SyncClient; private client!: SyncClient;
private data: Partial<{ private data: Partial<{
settings: Partial<SyncSettings>; settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> = {}; }> = {};
// Track sync state locally to avoid calling sync methods when disabled
private isSyncEnabled = true; private isSyncEnabled = true;
public constructor( public constructor(
@ -30,6 +17,7 @@ export class DeterministicAgent implements FileSystemOperations {
initialSettings: Partial<SyncSettings>, initialSettings: Partial<SyncSettings>,
logger: (msg: string) => void logger: (msg: string) => void
) { ) {
super();
this.clientId = clientId; this.clientId = clientId;
this.logger = logger; this.logger = logger;
this.data.settings = initialSettings; this.data.settings = initialSettings;
@ -52,7 +40,6 @@ export class DeterministicAgent implements FileSystemOperations {
await this.client.start(); await this.client.start();
// Verify connection is working
const connectionCheck = await this.client.checkConnection(); const connectionCheck = await this.client.checkConnection();
assert( assert(
connectionCheck.isSuccessful, connectionCheck.isSuccessful,
@ -60,87 +47,14 @@ export class DeterministicAgent implements FileSystemOperations {
); );
} }
// 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> { public async createFile(path: string, content: string): Promise<void> {
this.log(`Creating file ${path} with content: ${content}`); 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`); throw new Error(`File ${path} already exists`);
} }
const contentBytes = new TextEncoder().encode(content); 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) { if (this.isSyncEnabled) {
await this.client.syncLocallyCreatedFile(path); await this.client.syncLocallyCreatedFile(path);
} }
@ -149,9 +63,8 @@ export class DeterministicAgent implements FileSystemOperations {
public async updateFile(path: string, content: string): Promise<void> { public async updateFile(path: string, content: string): Promise<void> {
this.log(`Updating file ${path} with content: ${content}`); this.log(`Updating file ${path} with content: ${content}`);
const contentBytes = new TextEncoder().encode(content); const contentBytes = new TextEncoder().encode(content);
this.localFiles.set(path, contentBytes); this.files.set(path, contentBytes);
// Only sync if enabled
if (this.isSyncEnabled) { if (this.isSyncEnabled) {
await this.client.syncLocallyUpdatedFile({ relativePath: path }); await this.client.syncLocallyUpdatedFile({ relativePath: path });
} }
@ -159,16 +72,14 @@ export class DeterministicAgent implements FileSystemOperations {
public async renameFile(oldPath: string, newPath: string): Promise<void> { public async renameFile(oldPath: string, newPath: string): Promise<void> {
this.log(`Renaming file ${oldPath} to ${newPath}`); this.log(`Renaming file ${oldPath} to ${newPath}`);
// Update local state const file = this.files.get(oldPath);
const file = this.localFiles.get(oldPath);
if (!file) { if (!file) {
throw new Error(`File ${oldPath} does not exist`); throw new Error(`File ${oldPath} does not exist`);
} }
this.localFiles.set(newPath, file); this.files.set(newPath, file);
if (oldPath !== newPath) { if (oldPath !== newPath) {
this.localFiles.delete(oldPath); this.files.delete(oldPath);
} }
// Only sync if enabled
if (this.isSyncEnabled) { if (this.isSyncEnabled) {
await this.client.syncLocallyUpdatedFile({ await this.client.syncLocallyUpdatedFile({
oldPath, oldPath,
@ -179,9 +90,7 @@ export class DeterministicAgent implements FileSystemOperations {
public async deleteFile(path: string): Promise<void> { public async deleteFile(path: string): Promise<void> {
this.log(`Deleting file ${path}`); this.log(`Deleting file ${path}`);
// Update local state this.files.delete(path);
this.localFiles.delete(path);
// Only sync if enabled
if (this.isSyncEnabled) { if (this.isSyncEnabled) {
await this.client.syncLocallyDeletedFile(path); await this.client.syncLocallyDeletedFile(path);
} }

View file

@ -1,14 +1,18 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
import type { Logger } from "sync-client";
import { PING_URL } from "./consts";
export class ServerControl { export class ServerControl {
private process: ChildProcess | null = null; private process: ChildProcess | null = null;
private readonly serverPath: string; private readonly serverPath: string;
private readonly configPath: 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.serverPath = serverPath;
this.configPath = configPath; this.configPath = configPath;
this.logger = logger;
} }
public async start(): Promise<void> { public async start(): Promise<void> {
@ -16,7 +20,9 @@ export class ServerControl {
throw new Error("Server is already running"); 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; let startupError: string | null = null;
@ -26,53 +32,45 @@ export class ServerControl {
}); });
this.process.stdout?.on("data", (data: Buffer) => { 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) => { this.process.stderr?.on("data", (data: Buffer) => {
const msg = data.toString().trim(); const msg = data.toString().trim();
console.error(`[SERVER ERROR] ${msg}`); this.logger.error(`[SERVER ERROR] ${msg}`);
// Capture startup errors
if (msg.includes("Failed to") || msg.includes("Error")) { if (msg.includes("Failed to") || msg.includes("Error")) {
startupError = msg; startupError = msg;
} }
}); });
this.process.on("error", (err) => { this.process.on("error", (err) => {
console.error("[SERVER] Process error:", err); this.logger.error(`[SERVER] Process error: ${err.message}`);
startupError = err.message; startupError = err.message;
}); });
this.process.on("exit", (code, signal) => { 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; this.process = null;
}); });
// Give the process a moment to fail if it's going to
await sleep(100); await sleep(100);
// Check if process died during startup (exit handler sets this.process to null)
this.checkProcessAlive(startupError, "startup"); this.checkProcessAlive(startupError, "startup");
// Wait for server to be ready
await this.waitForReady(); await this.waitForReady();
// Final check that our process is still the one running
this.checkProcessAlive(startupError, "after startup"); this.checkProcessAlive(startupError, "after startup");
} }
public async waitForReady(maxAttempts = 30): Promise<void> { public async waitForReady(maxAttempts = 30): Promise<void> {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
const response = await fetch( const response = await fetch(PING_URL);
"http://localhost:3000/vaults/test/ping"
);
if (response.ok) { if (response.ok) {
console.log("[SERVER] Ready"); this.logger.info("[SERVER] Ready");
return; return;
} }
} catch { } catch {
// Server not ready yet // Server not ready yet, continue polling
} }
await sleep(100); await sleep(100);
} }
@ -83,7 +81,7 @@ export class ServerControl {
if (this.process?.pid === undefined) { if (this.process?.pid === undefined) {
throw new Error("Server is not running"); throw new Error("Server is not running");
} }
console.log("[SERVER] Pausing..."); this.logger.info("Server pausing...");
process.kill(this.process.pid, "SIGSTOP"); process.kill(this.process.pid, "SIGSTOP");
} }
@ -91,7 +89,7 @@ export class ServerControl {
if (this.process?.pid === undefined) { if (this.process?.pid === undefined) {
throw new Error("Server is not running"); throw new Error("Server is not running");
} }
console.log("[SERVER] Resuming..."); this.logger.info("Server resuming...");
process.kill(this.process.pid, "SIGCONT"); process.kill(this.process.pid, "SIGCONT");
} }
@ -100,7 +98,7 @@ export class ServerControl {
return; return;
} }
console.log("[SERVER] Stopping..."); this.logger.info("Server stopping...");
const { pid } = this.process; const { pid } = this.process;
return new Promise((resolve) => { return new Promise((resolve) => {
@ -113,10 +111,8 @@ export class ServerControl {
resolve(); resolve();
}); });
// Try graceful shutdown first
process.kill(pid, "SIGTERM"); process.kill(pid, "SIGTERM");
// Force kill after 5 seconds
setTimeout(() => { setTimeout(() => {
if (this.process?.pid !== undefined) { if (this.process?.pid !== undefined) {
process.kill(this.process.pid, "SIGKILL"); process.kill(this.process.pid, "SIGKILL");

View file

@ -1,24 +1,22 @@
/** export interface ClientState {
* Deterministic test framework for VaultLink sync testing. files: Map<string, string>;
* Allows defining exact sequences of operations to test specific scenarios. }
*/
export type TestStep = export type TestStep =
| { type: "create"; client: number; path: string; content: string } | { type: "create"; client: number; path: string; content: string }
| { type: "update"; client: number; path: string; content: string } | { type: "update"; client: number; path: string; content: string }
| { type: "rename"; client: number; oldPath: string; newPath: string } | { type: "rename"; client: number; oldPath: string; newPath: string }
| { type: "delete"; client: number; path: 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: "disable-sync"; client: number }
| { type: "enable-sync"; client: number } | { type: "enable-sync"; client: number }
| { type: "wait"; duration: number } // wait N milliseconds
| { type: "pause-server" } | { type: "pause-server" }
| { type: "resume-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-content"; client: number; path: string; content: string }
| { type: "assert-exists"; client: number; path: string } | { type: "assert-exists"; client: number; path: string }
| { type: "assert-not-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 { export interface TestDefinition {
name: string; name: string;

View file

@ -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 { DeterministicAgent } from "./deterministic-agent";
import type { ServerControl } from "./server-control"; import type { ServerControl } from "./server-control";
import type { SyncSettings } from "sync-client"; import type { SyncSettings, Logger } from "sync-client";
import { utils } from "sync-client";
import { sleep } from "./utils/sleep";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
import WebSocket from "ws"; import WebSocket from "ws";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@ -13,30 +16,28 @@ export class TestRunner {
private readonly serverControl: ServerControl; private readonly serverControl: ServerControl;
private readonly token: string; private readonly token: string;
private readonly remoteUri: string; private readonly remoteUri: string;
private readonly logBuffer: string[] = []; private readonly logger: Logger;
public constructor( public constructor(
serverControl: ServerControl, serverControl: ServerControl,
options: { logger: Logger,
token?: string; token: string,
remoteUri?: string; remoteUri: string
} = {}
) { ) {
this.serverControl = serverControl; this.serverControl = serverControl;
this.token = options.token ?? "test-token-change-me "; this.logger = logger;
this.remoteUri = options.remoteUri ?? "http://localhost:3000"; this.token = token;
this.remoteUri = remoteUri;
} }
public async runTest(test: TestDefinition): Promise<TestResult> { public async runTest(test: TestDefinition): Promise<TestResult> {
const startTime = Date.now(); const startTime = Date.now();
this.log(`\n${"=".repeat(80)}`); this.logger.info(`Running test: ${test.name}`);
this.log(`Running test: ${test.name}`);
if (test.description !== undefined && test.description !== "") { if (test.description !== undefined && test.description !== "") {
this.log(`Description: ${test.description}`); this.logger.info(`Description: ${test.description}`);
} }
this.log(`Clients: ${test.clients}`); this.logger.info(`Clients: ${test.clients}`);
this.log(`Steps: ${test.steps.length}`); this.logger.info(`Steps: ${test.steps.length}`);
this.log("=".repeat(80));
try { try {
// Initialize agents // Initialize agents
@ -45,7 +46,7 @@ export class TestRunner {
// Execute steps // Execute steps
for (let i = 0; i < test.steps.length; i++) { for (let i = 0; i < test.steps.length; i++) {
const step = test.steps[i]; const step = test.steps[i];
this.log( this.logger.info(
`\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}` `\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
); );
await this.executeStep(step); await this.executeStep(step);
@ -55,7 +56,7 @@ export class TestRunner {
await this.cleanup(); await this.cleanup();
const duration = Date.now() - startTime; 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 { return {
success: true, success: true,
@ -65,8 +66,8 @@ export class TestRunner {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
this.log(`\n✗ Test failed: ${test.name}`); this.logger.info(`\n✗ Test failed: ${test.name}`);
this.log(`Error: ${errorMessage}`); this.logger.info(`Error: ${errorMessage}`);
await this.cleanup(); 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<void> { private async initializeAgents(count: number): Promise<void> {
// Use unique vault name for each test run to avoid data interference
const vaultName = `test-${randomUUID()}`; const vaultName = `test-${randomUUID()}`;
this.log(`\nInitializing ${count} agents with vault: ${vaultName}`); this.logger.info(
`Initializing ${count} agents with vault: ${vaultName}`
);
const settings: Partial<SyncSettings> = { 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, isSyncEnabled: false,
token: this.token, token: this.token,
vaultName, vaultName,
@ -106,9 +95,8 @@ export class TestRunner {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const agent = new DeterministicAgent(i, settings, (msg) => { 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( await agent.init(
fetch, fetch,
@ -116,13 +104,10 @@ export class TestRunner {
WebSocket as unknown as typeof globalThis.WebSocket WebSocket as unknown as typeof globalThis.WebSocket
); );
this.agents.push(agent); this.agents.push(agent);
this.log(`Initialized client ${i}`); this.logger.info(`Initialized client ${i}`);
} }
// Wait for WebSocket connections to fully establish this.logger.info("All agents initialized");
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> { private async executeStep(step: TestStep): Promise<void> {
@ -156,7 +141,6 @@ export class TestRunner {
if (step.client !== undefined) { if (step.client !== undefined) {
await this.agents[step.client].waitForSync(); await this.agents[step.client].waitForSync();
} else { } else {
// Wait for all clients
for (const agent of this.agents) { for (const agent of this.agents) {
await agent.waitForSync(); await agent.waitForSync();
} }
@ -171,11 +155,6 @@ export class TestRunner {
await this.agents[step.client].enableSync(); await this.agents[step.client].enableSync();
break; break;
case "wait":
this.log(`Waiting ${step.duration}ms...`);
await sleep(step.duration);
break;
case "pause-server": case "pause-server":
this.serverControl.pause(); this.serverControl.pause();
break; break;
@ -185,22 +164,7 @@ export class TestRunner {
break; break;
case "barrier": case "barrier":
this.log( await this.waitForConvergence();
"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; break;
case "assert-content": case "assert-content":
@ -219,7 +183,7 @@ export class TestRunner {
break; break;
case "assert-consistent": case "assert-consistent":
await this.assertConsistent(); await this.assertConsistent(step.verify);
break; break;
default: { default: {
@ -229,18 +193,80 @@ export class TestRunner {
} }
} }
private async assertConsistent(): Promise<void> { private async waitForConvergence(maxAttempts = 50): Promise<void> {
this.log("Asserting all clients are consistent..."); 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<boolean> {
if (this.agents.length < 2) { if (this.agents.length < 2) {
this.log("Only one client, skipping consistency check"); return true;
return;
} }
const [referenceAgent] = this.agents; const [referenceAgent] = this.agents;
const referenceFiles = (await referenceAgent.getFiles()).sort(); 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<void> {
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(", ")}` `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
); );
@ -248,11 +274,10 @@ export class TestRunner {
const agent = this.agents[i]; const agent = this.agents[i];
const files = (await agent.getFiles()).sort(); const files = (await agent.getFiles()).sort();
this.log( this.logger.info(
`Client ${i} has ${files.length} files: ${files.join(", ")}` `Client ${i} has ${files.length} files: ${files.join(", ")}`
); );
// Check file lists match
assert( assert(
files.length === referenceFiles.length, files.length === referenceFiles.length,
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files` `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) { for (const file of referenceFiles) {
const referenceContent = const referenceContent = referenceState.files.get(file);
await referenceAgent.getFileContent(file);
const agentContent = await agent.getFileContent(file); const agentContent = await agent.getFileContent(file);
assert( 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<void> { private async cleanup(): Promise<void> {
this.log("\nCleaning up agents..."); this.logger.info("\nCleaning up agents...");
for (const agent of this.agents) { for (const agent of this.agents) {
await agent.cleanup(); await agent.cleanup();
} }
this.agents = []; this.agents = [];
this.log("Cleanup complete"); this.logger.info("Cleanup complete");
} }
} }

View file

@ -1,26 +1,5 @@
import type { TestDefinition } from "../test-definition"; 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 = { export const renameCreateConflictTest: TestDefinition = {
name: "Rename-Create Conflict", name: "Rename-Create Conflict",
description: description:
@ -28,41 +7,19 @@ export const renameCreateConflictTest: TestDefinition = {
"The system must resolve the conflict deterministically.", "The system must resolve the conflict deterministically.",
clients: 2, clients: 2,
steps: [ steps: [
// Enable sync on all clients first (agents start with sync disabled)
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
// Client 0 creates file A with "hi" and syncs
{ type: "create", client: 0, path: "A.md", content: "hi" }, { type: "create", client: 0, path: "A.md", content: "hi" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
// Client 1 syncs to get file A
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "assert-exists", client: 1, path: "A.md" }, { type: "assert-exists", client: 1, path: "A.md" },
{ type: "assert-content", client: 1, path: "A.md", content: "hi" }, { 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 }, { type: "disable-sync", client: 0 },
// Client 1 renames A to B and syncs
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "sync", client: 1 }, { 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" }, { 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: "enable-sync", client: 0 },
{ type: "barrier" }, // Wait for conflict resolution
// Give system time to propagate
{ type: "wait", duration: 500 },
{ type: "barrier" }, { type: "barrier" },
// Verify both clients converge to the same state
{ type: "assert-consistent" } { type: "assert-consistent" }
] ]
}; };

View file

@ -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 = { export const writeWriteConflictTest: TestDefinition = {
name: "Write/Write Conflict", name: "Write/Write Conflict",
description: description:
@ -20,27 +18,13 @@ export const writeWriteConflictTest: TestDefinition = {
"The system should resolve the conflict and both clients should converge.", "The system should resolve the conflict and both clients should converge.",
clients: 2, clients: 2,
steps: [ steps: [
// Both clients go offline
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { 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: 0, path: "A.md", content: "hello" },
{ type: "create", client: 1, path: "A.md", content: "world" }, { 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: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
// Wait for sync to complete and propagate
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: verifyMergedContent }
// 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

@ -17,6 +17,7 @@ export default [
}, },
extends: [eslint.configs.recommended, tseslint.configs.all], extends: [eslint.configs.recommended, tseslint.configs.all],
rules: { rules: {
"no-console": "error",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",

View file

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import * as path from "path"; import * as path from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import * as fsSync from "fs"; import * as fsSync from "fs";
@ -65,7 +66,7 @@ async function main(): Promise<void> {
console.log( console.log(
styleText("VaultLink Local CLI", "bold", "cyan") + styleText("VaultLink Local CLI", "bold", "cyan") +
colorize(` v${packageJson.version}`, "dim") colorize(` v${packageJson.version}`, "dim")
); );
console.log(colorize("=".repeat(50), "dim")); console.log(colorize("=".repeat(50), "dim"));
console.log( console.log(

View file

@ -1,4 +1,5 @@
#!/usr/bin/env node #!/usr/bin/env node
/* eslint-disable no-console */
/** /**
* Healthcheck script for Docker container * Healthcheck script for Docker container

View file

@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
import { logToConsole } from "./utils/debugging/log-to-console"; import { logToConsole } from "./utils/debugging/log-to-console";
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory"; import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-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 { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = { export const debugging = {
slowFetchFactory, slowFetchFactory,
slowWebSocketFactory, slowWebSocketFactory,
logToConsole logToConsole,
InMemoryFileSystem
}; };
export const utils = { export const utils = {

View file

@ -74,12 +74,6 @@ export class UnrestrictedSyncer {
force?: boolean; force?: boolean;
document: DocumentRecord; document: DocumentRecord;
}): Promise<void> { }): Promise<void> {
// this.history.addHistoryEntry({
// status: SyncStatus.SUCCESS,
// details: updateDetails,
// message: `Successfully uploaded locally created file`
// });
const updateDetails: const updateDetails:
| SyncCreateDetails | SyncCreateDetails
| SyncUpdateDetails | SyncUpdateDetails
@ -221,15 +215,6 @@ export class UnrestrictedSyncer {
relativePath: response.relativePath 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) { if (!response.isDeleted) {
this.history.addHistoryEntry({ this.history.addHistoryEntry({
status: SyncStatus.SUCCESS, status: SyncStatus.SUCCESS,
@ -246,7 +231,7 @@ export class UnrestrictedSyncer {
relativePath: document.relativePath relativePath: document.relativePath
}, },
message: message:
"File has been deleted remotely, so we deleted it locally", "Successfully deleted file which had been deleted remotely",
author: response.userId, author: response.userId,
timestamp: new Date(response.updatedDate) timestamp: new Date(response.updatedDate)
}); });

View file

@ -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<string, Uint8Array>();
public async listFilesRecursively(
_root: RelativePath | undefined = undefined // we don't use multi-level paths during tests
): Promise<RelativePath[]> {
return Array.from(this.files.keys());
}
public async read(path: RelativePath): Promise<Uint8Array> {
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<void> {
this.files.set(path, content);
}
public async atomicUpdateText(
path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> {
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<number> {
return (await this.read(path)).length;
}
public async exists(path: RelativePath): Promise<boolean> {
return this.files.has(path);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
public async createDirectory(_path: RelativePath): Promise<void> {
// This doesn't mean anything in our virtual FS representation
}
public async delete(path: RelativePath): Promise<void> {
this.files.delete(path);
}
public async rename(
oldPath: RelativePath,
newPath: RelativePath
): Promise<void> {
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);
}
}
}

View file

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import type { Logger, LogLine } from "../../tracing/logger"; import type { Logger, LogLine } from "../../tracing/logger";
import { LogLevel } from "../../tracing/logger"; import { LogLevel } from "../../tracing/logger";

View file

@ -1,3 +1,4 @@
/* eslint-disable no-console */
import { choose } from "../utils/choose"; import { choose } from "../utils/choose";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { assert } from "../utils/assert"; import { assert } from "../utils/assert";
@ -94,22 +95,12 @@ export class MockAgent extends MockClient {
} }
public async createInitialDocuments(count: number): Promise<void> { public async createInitialDocuments(count: number): Promise<void> {
this.client.logger.info(`Creating ${count} initial documents`);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const file = `initial-${i}.md`; const file = `initial-${i}.md`;
this.doNotTouchWhileOffline.push(file);
const content = this.getContent(); const content = this.getContent();
this.client.logger.info( this.files.set(file, new TextEncoder().encode(` ${content} `));
`Creating initial file ${file} with content ${content}`
);
await this.create(file, new TextEncoder().encode(` ${content} `), {
ignoreSlowFileEvents: true
});
} }
// Wait for all initial documents to sync
await this.client.waitUntilFinished();
this.client.logger.info(`Initial documents created and synced`);
} }
public async waitUntilSynced(): Promise<void> { public async waitUntilSynced(): Promise<void> {
@ -159,7 +150,7 @@ export class MockAgent extends MockClient {
JSON.stringify(this.data, null, 2) JSON.stringify(this.data, null, 2)
); );
this.client.logger.info( this.client.logger.info(
JSON.stringify(this.localFiles, null, 2) JSON.stringify(this.files, null, 2)
); );
throw error; throw error;
} }
@ -192,14 +183,14 @@ export class MockAgent extends MockClient {
} }
public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { public assertFileSystemsAreConsistent(otherAgent: MockAgent): void {
const globalFiles = Array.from(otherAgent.localFiles.keys()); const globalFiles = Array.from(otherAgent.files.keys());
const localFiles = Array.from(this.localFiles.keys()); const localFiles = Array.from(this.files.keys());
const missingInOther = localFiles.filter( const missingInOther = localFiles.filter(
(file) => !otherAgent.localFiles.has(file) (file) => !otherAgent.files.has(file)
); );
const missingInLocal = globalFiles.filter( const missingInLocal = globalFiles.filter(
(file) => !this.localFiles.has(file) (file) => !this.files.has(file)
); );
try { try {
@ -214,10 +205,10 @@ export class MockAgent extends MockClient {
for (const file of globalFiles) { for (const file of globalFiles) {
const localContent = new TextDecoder().decode( const localContent = new TextDecoder().decode(
this.localFiles.get(file) this.files.get(file)
); );
const otherContent = new TextDecoder().decode( const otherContent = new TextDecoder().decode(
otherAgent.localFiles.get(file) otherAgent.files.get(file)
); );
assert( assert(
localContent === otherContent, localContent === otherContent,
@ -229,15 +220,13 @@ export class MockAgent extends MockClient {
"Local data: " + JSON.stringify(this.data, null, 2) "Local data: " + JSON.stringify(this.data, null, 2)
); );
this.client.logger.info( this.client.logger.info(
"Local files: " + "Local files: " + Array.from(otherAgent.files.keys()).join(", ")
Array.from(otherAgent.localFiles.keys()).join(", ")
); );
otherAgent.client.logger.info( otherAgent.client.logger.info(
"Local data: " + JSON.stringify(otherAgent.data, null, 2) "Local data: " + JSON.stringify(otherAgent.data, null, 2)
); );
otherAgent.client.logger.info( otherAgent.client.logger.info(
"Local files: " + "Local files: " + Array.from(otherAgent.files.keys()).join(", ")
Array.from(otherAgent.localFiles.keys()).join(", ")
); );
throw e; throw e;
@ -254,9 +243,9 @@ export class MockAgent extends MockClient {
} }
for (const content of this.writtenContents) { 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() return new TextDecoder()
.decode(this.localFiles.get(key)) .decode(this.files.get(key))
.includes(content); .includes(content);
}); });
@ -278,7 +267,7 @@ export class MockAgent extends MockClient {
const [file] = found; const [file] = found;
const fileContent = new TextDecoder().decode( const fileContent = new TextDecoder().decode(
this.localFiles.get(file) this.files.get(file)
); );
assert( assert(
fileContent.split(content).length == 2, fileContent.split(content).length == 2,

View file

@ -2,13 +2,12 @@ import type { StoredDatabase, TextWithCursors } from "sync-client";
import { assert } from "../utils/assert"; import { assert } from "../utils/assert";
import { import {
type RelativePath, type RelativePath,
type FileSystemOperations,
type SyncSettings, type SyncSettings,
SyncClient SyncClient,
debugging
} from "sync-client"; } from "sync-client";
export class MockClient implements FileSystemOperations { export class MockClient extends debugging.InMemoryFileSystem {
protected readonly localFiles = new Map<string, Uint8Array>();
protected client!: SyncClient; protected client!: SyncClient;
protected data: Partial<{ protected data: Partial<{
@ -20,6 +19,7 @@ export class MockClient implements FileSystemOperations {
initialSettings: Partial<SyncSettings>, initialSettings: Partial<SyncSettings>,
protected readonly useSlowFileEvents: boolean protected readonly useSlowFileEvents: boolean
) { ) {
super();
this.data.settings = initialSettings; this.data.settings = initialSettings;
} }
@ -40,28 +40,6 @@ export class MockClient implements FileSystemOperations {
await this.client.start(); await this.client.start();
} }
public async listFilesRecursively(
_root: RelativePath | undefined = undefined // we don't use multi-level paths during tests
): 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 create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array, newContent: Uint8Array,
@ -69,13 +47,13 @@ export class MockClient implements FileSystemOperations {
ignoreSlowFileEvents: false ignoreSlowFileEvents: false
} }
): Promise<void> { ): Promise<void> {
if (this.localFiles.has(path)) { if (this.files.has(path)) {
throw new Error(`File ${path} already exists`); throw new Error(`File ${path} already exists`);
} }
this.client.logger.info( this.client.logger.info(
`Creating file ${path} with content ${new TextDecoder().decode(newContent)}` `Creating file ${path} with content ${new TextDecoder().decode(newContent)}`
); );
this.localFiles.set(path, newContent); this.files.set(path, newContent);
this.executeFileOperation( this.executeFileOperation(
async () => this.client.syncLocallyCreatedFile(path), async () => this.client.syncLocallyCreatedFile(path),
@ -83,25 +61,21 @@ export class MockClient implements FileSystemOperations {
); );
} }
public async createDirectory(_path: RelativePath): Promise<void> { public override async atomicUpdateText(
// This doesn't mean anything in our virtual FS representation
}
public async atomicUpdateText(
path: RelativePath, path: RelativePath,
updater: (currentContent: TextWithCursors) => TextWithCursors, updater: (currentContent: TextWithCursors) => TextWithCursors,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
ignoreSlowFileEvents: false ignoreSlowFileEvents: false
} }
): Promise<string> { ): Promise<string> {
const file = this.localFiles.get(path); const file = this.files.get(path);
if (!file) { if (!file) {
throw new Error(`File ${path} does not exist`); throw new Error(`File ${path} does not exist`);
} }
const currentContent = new TextDecoder().decode(file); const currentContent = new TextDecoder().decode(file);
const newContent = updater({ text: currentContent, cursors: [] }).text; const newContent = updater({ text: currentContent, cursors: [] }).text;
const newContentUint8Array = new TextEncoder().encode(newContent); const newContentUint8Array = new TextEncoder().encode(newContent);
this.localFiles.set(path, newContentUint8Array); this.files.set(path, newContentUint8Array);
if (!this.useSlowFileEvents) { if (!this.useSlowFileEvents) {
const existingParts = currentContent const existingParts = currentContent
@ -109,13 +83,13 @@ export class MockClient implements FileSystemOperations {
.map((part) => part.trim()); .map((part) => part.trim());
const newParts = newContent.split(" ").map((part) => part.trim()); const newParts = newContent.split(" ").map((part) => part.trim());
existingParts.forEach((part) => existingParts.forEach((part) =>
// all changes should be additive // all changes should be additive
{ {
assert( assert(
newParts.includes(part), newParts.includes(part),
`Part ${part} not found in new content: ${newContent}` `Part ${part} not found in new content: ${newContent}`
); );
} }
); );
} }
@ -134,9 +108,12 @@ export class MockClient implements FileSystemOperations {
return newContent; return newContent;
} }
public async write(path: RelativePath, content: Uint8Array): Promise<void> { public override async write(
const hasExisted = this.localFiles.has(path); path: RelativePath,
this.localFiles.set(path, content); content: Uint8Array
): Promise<void> {
const hasExisted = this.files.has(path);
this.files.set(path, content);
this.client.logger.info( this.client.logger.info(
`Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` `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, path: RelativePath,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
ignoreSlowFileEvents: false ignoreSlowFileEvents: false
} }
): Promise<void> { ): Promise<void> {
this.client.logger.info( 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( this.executeFileOperation(
async () => this.client.syncLocallyDeletedFile(path), async () => this.client.syncLocallyDeletedFile(path),
@ -170,20 +147,20 @@ export class MockClient implements FileSystemOperations {
); );
} }
public async rename( public override async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath, newPath: RelativePath,
{ ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = {
ignoreSlowFileEvents: false ignoreSlowFileEvents: false
} }
): Promise<void> { ): Promise<void> {
const file = this.localFiles.get(oldPath); const file = this.files.get(oldPath);
if (!file) { if (!file) {
throw new Error(`File ${oldPath} does not exist`); throw new Error(`File ${oldPath} does not exist`);
} }
this.localFiles.set(newPath, file); this.files.set(newPath, file);
if (oldPath !== newPath) { if (oldPath !== newPath) {
this.localFiles.delete(oldPath); this.files.delete(oldPath);
} }
this.client.logger.info( this.client.logger.info(

View file

@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid";
import { randomCasing } from "./utils/random-casing"; import { randomCasing } from "./utils/random-casing";
const TEST_ITERATIONS = 5; 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. // Simulate async file access by injecting waiting time before returning from file operations.
let slowFileEvents = false; let slowFileEvents = false;
@ -65,8 +65,6 @@ async function runTest({
} }
try { try {
await utils.awaitAll(clients.map(async (client) => client.init()));
for (const client of clients) { for (const client of clients) {
const initialDocCount = Math.floor( const initialDocCount = Math.floor(
Math.random() * MAX_INITIAL_DOCS 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++) { for (let i = 0; i < iterations; i++) {
logger.info(`Iteration ${i + 1}/${iterations}`); logger.info(`Iteration ${i + 1}/${iterations}`);
await utils.awaitAll(clients.map(async (client) => client.act())); await utils.awaitAll(clients.map(async (client) => client.act()));
@ -217,5 +219,8 @@ runTests()
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
logger.error(`Error - tests failed with ${error}`); logger.error(`Error - tests failed with ${error}`);
if (error instanceof Error && error.stack) {
logger.error(error.stack);
}
process.exit(1); process.exit(1);
}); });

View file

@ -9,24 +9,24 @@ server:
max_clients_per_vault: 256 max_clients_per_vault: 256
response_timeout: 30m response_timeout: 30m
mergeable_file_extensions: mergeable_file_extensions:
- md - md
- txt - txt
users: users:
user_configs: user_configs:
- name: admin - name: admin
token: test-token-change-me token: test-token-change-me
vault_access: vault_access:
type: allow_access_to_all type: allow_access_to_all
- name: other-admin - name: other-admin
token: test-token-change-me2 token: test-token-change-me2
vault_access: vault_access:
type: allow_access_to_all type: allow_access_to_all
- name: test - name: test
token: other-test-token token: other-test-token
vault_access: vault_access:
type: allow_list type: allow_list
allowed: allowed:
- default - default
logging: logging:
log_directory: logs log_directory: logs
log_rotation: 7days log_rotation: 7days