Refactor tests
This commit is contained in:
parent
16afe31e89
commit
f53ac121e8
19 changed files with 353 additions and 571 deletions
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
5
frontend/deterministic-tests/src/consts.ts
Normal file
5
frontend/deterministic-tests/src/consts.ts
Normal 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";
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue