From 16afe31e89b12db2291e297afe39bd69cffa75f8 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 13 Jan 2026 21:52:42 +0000 Subject: [PATCH] Add deterministic tests and lint --- .gitignore | 10 +- README.md | 21 +- frontend/deterministic-tests/README.md | 283 +++++++++++++++++ frontend/deterministic-tests/package.json | 24 ++ frontend/deterministic-tests/src/cli.ts | 233 ++++++++++++++ .../src/deterministic-agent.ts | 267 ++++++++++++++++ .../deterministic-tests/src/server-control.ts | 148 +++++++++ .../src/test-definition.ts | 35 +++ .../deterministic-tests/src/test-runner.ts | 292 ++++++++++++++++++ .../src/tests/rename-create-conflict.test.ts | 68 ++++ .../src/tests/write-write-conflict.test.ts | 46 +++ .../deterministic-tests/src/utils/assert.ts | 5 + .../deterministic-tests/src/utils/sleep.ts | 3 + frontend/deterministic-tests/tsconfig.json | 12 + .../deterministic-tests/webpack.config.js | 30 ++ .../obsidian-plugin/src/vault-link-plugin.ts | 6 +- frontend/package-lock.json | 52 ++++ frontend/package.json | 3 +- .../sync-client/src/persistence/database.ts | 4 +- .../sync-client/src/services/sync-service.ts | 10 +- .../services/types/CreateDocumentVersion.ts | 1 - frontend/sync-client/src/sync-client.ts | 4 +- .../sync-client/src/sync-operations/syncer.ts | 122 ++++---- .../sync-operations/unrestricted-syncer.ts | 138 ++++----- .../src/utils/data-structures/locks.ts | 2 +- frontend/test-client/src/agent/mock-agent.ts | 25 +- frontend/test-client/src/agent/mock-client.ts | 78 +++-- frontend/test-client/src/cli.ts | 6 +- sync-server/config-e2e.yml | 32 +- 29 files changed, 1738 insertions(+), 222 deletions(-) create mode 100644 frontend/deterministic-tests/README.md create mode 100644 frontend/deterministic-tests/package.json create mode 100644 frontend/deterministic-tests/src/cli.ts create mode 100644 frontend/deterministic-tests/src/deterministic-agent.ts create mode 100644 frontend/deterministic-tests/src/server-control.ts create mode 100644 frontend/deterministic-tests/src/test-definition.ts create mode 100644 frontend/deterministic-tests/src/test-runner.ts create mode 100644 frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/tests/write-write-conflict.test.ts create mode 100644 frontend/deterministic-tests/src/utils/assert.ts create mode 100644 frontend/deterministic-tests/src/utils/sleep.ts create mode 100644 frontend/deterministic-tests/tsconfig.json create mode 100644 frontend/deterministic-tests/webpack.config.js diff --git a/.gitignore b/.gitignore index a1c1ac4f..c291b71a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,15 +7,19 @@ node_modules # Frontend build folders frontend/*/dist -sync-server/db.sqlite3* -sync-server/databases - # Rust build folders sync-server/target sync-server/artifacts sync-server/bindings/*.ts +# build folders +sync-server/db.sqlite3* +sync-server/databases +frontend/deterministic-tests/databases + *.log *.sqlx target + +.task diff --git a/README.md b/README.md index 8d29cd0e..7ffb78ca 100644 --- a/README.md +++ b/README.md @@ -46,41 +46,40 @@ npm install npm run dev ``` -### Scripts +### Common Tasks + +This project uses [Taskfile](https://taskfile.dev/) for task automation. Run `task --list` to see all available tasks. #### Before pushing ```sh -scripts/check.sh --fix +task check:fix ``` #### Update HTTP API TS bindings ```sh -scripts/update-api-types.sh +task update-api-types ``` #### Publish new version ```sh -scripts/bump-version.sh patch +task release:bump -- patch ``` #### Run E2E tests ```sh -scripts/e2e.sh 8 +task e2e -- 8 ``` -And to clean up the logs & database files, run `scripts/clean-up.sh` +And to clean up the logs & database files, run `task clean` ## Projects - [Sync server](./sync-server/README.md) - - - - - a create that has been processed by the server but got lost on the way back will create a 2nd doc if it gets edited + +remove force merge everywhere diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md new file mode 100644 index 00000000..7c0e7c1c --- /dev/null +++ b/frontend/deterministic-tests/README.md @@ -0,0 +1,283 @@ +# Deterministic Testing Framework + +A framework for defining and running deterministic tests for VaultLink sync operations. Unlike the fuzz testing approach, these tests execute exact sequences of operations to verify specific conflict resolution scenarios. + +## Overview + +The deterministic testing framework allows you to: + +- Define exact sequences of client operations in TypeScript +- Control both client and server processes (pause/resume) +- Test specific conflict scenarios (write/write, rename/create, etc.) +- Verify that the system resolves conflicts consistently + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Test Definition (TypeScript) │ +│ - Declare steps sequentially │ +│ - Specify client operations │ +│ - Add assertions │ +└──────────────┬──────────────────────────────┘ + │ + v +┌─────────────────────────────────────────────┐ +│ Test Runner │ +│ - Initializes clients │ +│ - Executes steps in order │ +│ - Manages server lifecycle │ +└──────────────┬──────────────────────────────┘ + │ + ├─→ DeterministicAgent (per client) + │ └─→ SyncClient + │ + └─→ ServerControl + └─→ sync_server process +``` + +## Test Definition Format + +Tests are defined using the `TestDefinition` interface: + +```typescript +interface TestDefinition { + name: string; + description?: string; + clients: number; + steps: TestStep[]; +} +``` + +### Available Steps + +#### File Operations + +```typescript +{ type: "create", client: 0, path: "file.md", content: "hello" } +{ type: "update", client: 0, path: "file.md", content: "world" } +{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" } +{ type: "delete", client: 0, path: "file.md" } +``` + +#### Sync Control + +```typescript +{ type: "sync", client: 0 } // Wait for specific client +{ type: "sync" } // Wait for all clients +{ type: "barrier" } // Wait for all pending ops +{ type: "disable-sync", client: 0 } +{ type: "enable-sync", client: 0 } +``` + +#### Server Control + +```typescript +{ type: "pause-server" } // Pause server process +{ type: "resume-server" } // Resume server process +{ type: "wait", duration: 500 } // Wait N milliseconds +``` + +#### Assertions + +```typescript +{ type: "assert-content", client: 0, path: "file.md", content: "hello" } +{ type: "assert-exists", client: 0, path: "file.md" } +{ type: "assert-not-exists", client: 0, path: "file.md" } +{ type: "assert-consistent" } // All clients have same state +``` + +## Example Tests + +### Write/Write Conflict + +Two clients create the same file with different content: + +```typescript +export const writeWriteConflictTest: TestDefinition = { + name: "Write/Write Conflict", + clients: 2, + steps: [ + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + { type: "create", client: 0, path: "A.md", content: "hello" }, + { type: "create", client: 1, path: "A.md", content: "world" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { type: "wait", duration: 500 }, + { type: "barrier" }, + { type: "assert-consistent" } + ] +}; +``` + +### Rename/Create Conflict + +Client 1 renames A→B while Client 0 creates B: + +```typescript +export const renameCreateConflictTest: TestDefinition = { + name: "Rename-Create Conflict", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "hi" }, + { type: "sync", client: 0 }, + { type: "sync", client: 1 }, + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 1 }, + { type: "disable-sync", client: 0 }, + { type: "create", client: 0, path: "B.md", content: "hi" }, + { type: "enable-sync", client: 0 }, + { type: "barrier" }, + { type: "wait", duration: 500 }, + { type: "barrier" }, + { type: "assert-consistent" } + ] +}; +``` + +## Running Tests + +### Build and Run + +```bash +# From frontend/deterministic-tests +npm run test +``` + +### Run Specific Test + +```bash +npm run test -- --test write-write-conflict +``` + +### List Available Tests + +```bash +npm run test -- --list +``` + +### Advanced Options + +```bash +# Use custom server binary +npm run test -- --server /path/to/sync_server + +# Use custom config +npm run test -- --config /path/to/config.yml + +# Don't manage server (assume it's already running) +npm run test -- --no-manage-server +``` + +## Creating New Tests + +1. Create a new test file in `src/tests/`: + +```typescript +// my-test.test.ts +import type { TestDefinition } from "../test-definition"; + +export const myTest: TestDefinition = { + name: "My Test", + description: "What this test verifies", + clients: 2, + steps: [ + // Your test steps here + ] +}; +``` + +2. Register the test in `src/cli.ts`: + +```typescript +import { myTest } from "./tests/my-test.test"; + +const TESTS: Record = { + // ... existing tests + "my-test": myTest +}; +``` + +3. Build and run: + +```bash +npm run test -- --test my-test +``` + +## Key Concepts + +### Synchronization Points + +Use explicit sync barriers to ensure operations complete: + +- `{ type: "sync", client: 0 }` - Wait for client 0 to finish pending ops +- `{ type: "barrier" }` - Wait for all clients to finish +- `{ type: "wait", duration: 500 }` - Wait for propagation + +### Offline Testing + +Disable sync to simulate offline edits: + +```typescript +{ type: "disable-sync", client: 0 }, +{ type: "create", client: 0, path: "file.md", content: "offline edit" }, +{ type: "enable-sync", client: 0 }, // Sync when back online +``` + +### Server Control + +Pause the server to test reconnection: + +```typescript +{ type: "pause-server" }, +{ type: "create", client: 0, path: "file.md", content: "while paused" }, +{ type: "resume-server" }, +{ type: "barrier" } +``` + +### Assertions + +Always end tests with consistency checks: + +```typescript +{ + type: "assert-consistent"; +} // Verify all clients converged +``` + +## Troubleshooting + +### Server Won't Start + +- Ensure server is built: `cd sync-server && cargo build` +- Check config file exists: `sync-server/config-e2e.yml` +- Verify port 3000 is available + +### Test Hangs + +- Increase wait durations for slow systems +- Add more `{ type: "barrier" }` steps +- Check server logs for errors + +### Assertion Failures + +- Add `{ type: "wait", duration: 1000 }` before assertions +- Check if conflict resolution is working as expected +- Review test steps for logic errors + +## Comparison to Fuzz Tests + +| Aspect | Fuzz Tests | Deterministic Tests | +| --------------- | --------------- | ------------------------- | +| Operations | Random | Explicit sequence | +| Reproducibility | Difficult | Perfect | +| Coverage | Broad | Targeted | +| Debugging | Hard | Easy | +| Use Case | Find edge cases | Verify specific scenarios | + +Use both approaches: + +- Fuzz tests for discovering unexpected issues +- Deterministic tests for verifying specific fixes diff --git a/frontend/deterministic-tests/package.json b/frontend/deterministic-tests/package.json new file mode 100644 index 00000000..9a9e9b3e --- /dev/null +++ b/frontend/deterministic-tests/package.json @@ -0,0 +1,24 @@ +{ + "name": "deterministic-tests", + "version": "0.14.0", + "private": true, + "bin": { + "deterministic-tests": "./dist/cli.js" + }, + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "npm run build && node dist/cli.js" + }, + "devDependencies": { + "@types/node": "^25.0.2", + "@types/ws": "^8.5.13", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.4", + "tslib": "2.8.1", + "typescript": "5.9.3", + "webpack": "^5.103.0", + "webpack-cli": "^6.0.1", + "ws": "^8.18.0" + } +} diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts new file mode 100644 index 00000000..6e68e727 --- /dev/null +++ b/frontend/deterministic-tests/src/cli.ts @@ -0,0 +1,233 @@ +#!/usr/bin/env node + +import { TestRunner } from "./test-runner"; +import { ServerControl } from "./server-control"; +import type { TestDefinition } from "./test-definition"; +import { writeWriteConflictTest } from "./tests/write-write-conflict.test"; +import { renameCreateConflictTest } from "./tests/rename-create-conflict.test"; +import * as path from "node:path"; +import * as fs from "node:fs"; + +// Global error handlers to catch unhandled errors +process.on("unhandledRejection", (reason, promise) => { + console.error("Unhandled Rejection at:", promise); + console.error("Reason:", reason); + process.exit(1); +}); + +process.on("uncaughtException", (error) => { + console.error("Uncaught Exception:", error); + process.exit(1); +}); + +// Available tests - using Partial to allow undefined lookup +const TESTS: Partial> = { + "write-write-conflict": writeWriteConflictTest, + "rename-create-conflict": renameCreateConflictTest +}; + +function printHelp(): void { + console.log(` +Deterministic Test Runner for VaultLink + +Usage: + npm run test [options] + +Options: + --test Run specific test (or "all") + --list List available tests + --server Path to sync_server binary (default: auto-detect) + --config Path to config file (default: config-e2e.yml) + --no-manage-server Don't start/stop server (assume it's running) + --help, -h Show this help + +Examples: + npm run test + npm run test -- --test write-write-conflict + npm run test -- --test all + npm run test -- --list + npm run test -- --no-manage-server --test rename-create-conflict +`); +} + +async function main(): Promise { + const args = process.argv.slice(2); + + // Parse arguments + let testName: string | undefined = undefined; + let serverPath: string | undefined = undefined; + let configPath: string | undefined = undefined; + let manageServer = true; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--test" && i + 1 < args.length) { + testName = args[++i]; + } else if (arg === "--server" && i + 1 < args.length) { + serverPath = args[++i]; + } else if (arg === "--config" && i + 1 < args.length) { + configPath = args[++i]; + } else if (arg === "--no-manage-server") { + manageServer = false; + } else if (arg === "--list") { + console.log("\nAvailable tests:"); + for (const [name, test] of Object.entries(TESTS)) { + if (test !== undefined) { + console.log(` ${name}: ${test.description ?? test.name}`); + } + } + process.exit(0); + } else if (arg === "--help" || arg === "-h") { + printHelp(); + process.exit(0); + } + } + + // Default values + if (serverPath === undefined) { + // Try to find project root from current working directory + const cwd = process.cwd(); + let projectRoot = cwd; + + // If we're in frontend/deterministic-tests, go up two levels + if ( + cwd.endsWith("frontend/deterministic-tests") || + cwd.endsWith("frontend\\deterministic-tests") + ) { + projectRoot = path.resolve(cwd, "../.."); + } + // If we're in frontend, go up one level + else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) { + projectRoot = path.resolve(cwd, ".."); + } + + serverPath = path.join( + projectRoot, + "sync-server/target/debug/sync_server" + ); + + // Check if server binary exists + if (!fs.existsSync(serverPath)) { + console.error(`Server binary not found at: ${serverPath}`); + console.error( + "Please build the server first: cd sync-server && cargo build" + ); + console.error(`Current working directory: ${cwd}`); + console.error(`Project root detected as: ${projectRoot}`); + process.exit(1); + } + } + + if (configPath === undefined) { + const cwd = process.cwd(); + let projectRoot = cwd; + + if ( + cwd.endsWith("frontend/deterministic-tests") || + cwd.endsWith("frontend\\deterministic-tests") + ) { + projectRoot = path.resolve(cwd, "../.."); + } else if (cwd.endsWith("frontend") || cwd.endsWith("frontend\\")) { + projectRoot = path.resolve(cwd, ".."); + } + + configPath = path.join(projectRoot, "sync-server/config-e2e.yml"); + + if (!fs.existsSync(configPath)) { + console.error(`Config file not found at: ${configPath}`); + process.exit(1); + } + } + + // Determine which tests to run + const testsToRun: TestDefinition[] = []; + + // Collect all defined tests + const allTests: TestDefinition[] = []; + for (const test of Object.values(TESTS)) { + if (test !== undefined) { + allTests.push(test); + } + } + + if (testName !== undefined) { + if (testName === "all") { + testsToRun.push(...allTests); + } else { + const test = TESTS[testName]; + if (test === undefined) { + console.error(`Unknown test: ${testName}`); + console.error( + `Available tests: ${Object.keys(TESTS).join(", ")}, all` + ); + process.exit(1); + } + testsToRun.push(test); + } + } else { + // Default: run all tests + testsToRun.push(...allTests); + } + + console.log(`\nDeterministic Test Suite`); + console.log("=".repeat(80)); + console.log(`Server: ${serverPath}`); + console.log(`Config: ${configPath}`); + console.log(`Manage server: ${manageServer}`); + console.log(`Tests to run: ${testsToRun.length}`); + console.log(`${"=".repeat(80)}\n`); + + // Initialize server control + const serverControl = new ServerControl(serverPath, configPath); + + let allPassed = true; + + try { + // Start server if we're managing it + if (manageServer) { + await serverControl.start(); + } else { + console.log("Assuming server is already running..."); + await serverControl.waitForReady(); + } + + // Run tests + for (const test of testsToRun) { + const runner = new TestRunner(serverControl); + const result = await runner.runTest(test); + + if (!result.success) { + allPassed = false; + console.error(`\n✗ FAILED: ${test.name}`); + console.error(`Error: ${result.error}`); + } else { + console.log(`\n✓ PASSED: ${test.name} (${result.duration}ms)`); + } + + // Add delay between tests + if (testsToRun.indexOf(test) < testsToRun.length - 1) { + console.log("\nWaiting 2s before next test...\n"); + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + } finally { + // Stop server if we're managing it + if (manageServer) { + await serverControl.stop(); + } + } + + console.log(`\n${"=".repeat(80)}`); + if (allPassed) { + console.log("✓ All tests passed!"); + process.exit(0); + } else { + console.log("✗ Some tests failed"); + process.exit(1); + } +} + +main().catch((err: unknown) => { + console.error("Unexpected error:", err); + process.exit(1); +}); diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts new file mode 100644 index 00000000..66933d36 --- /dev/null +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -0,0 +1,267 @@ +import type { StoredDatabase, TextWithCursors } from "sync-client"; +import type { + RelativePath, + FileSystemOperations, + SyncSettings +} from "sync-client"; +import { SyncClient } from "sync-client"; +import { assert } from "./utils/assert"; + +/** + * DeterministicAgent - A test agent that properly awaits all sync operations. + * + * Unlike MockClient which fires-and-forgets sync operations, this class + * ensures each operation is fully registered with SyncClient before returning. + */ +export class DeterministicAgent implements FileSystemOperations { + public readonly clientId: number; + private readonly logger: (msg: string) => void; + private readonly localFiles = new Map(); + private client!: SyncClient; + private data: Partial<{ + settings: Partial; + database: Partial; + }> = {}; + // Track sync state locally to avoid calling sync methods when disabled + private isSyncEnabled = true; + + public constructor( + clientId: number, + initialSettings: Partial, + logger: (msg: string) => void + ) { + this.clientId = clientId; + this.logger = logger; + this.data.settings = initialSettings; + this.isSyncEnabled = initialSettings.isSyncEnabled !== false; + } + + public async init( + fetchImplementation: typeof globalThis.fetch, + webSocketImplementation: typeof globalThis.WebSocket + ): Promise { + this.client = await SyncClient.create({ + fs: this, + persistence: { + load: async () => this.data, + save: async (data) => void (this.data = data) + }, + fetch: fetchImplementation, + webSocket: webSocketImplementation + }); + + await this.client.start(); + + // Verify connection is working + const connectionCheck = await this.client.checkConnection(); + assert( + connectionCheck.isSuccessful, + `Client ${this.clientId} connection check failed` + ); + } + + // FileSystemOperations implementation + public async listFilesRecursively( + _root?: RelativePath + ): Promise { + return Array.from(this.localFiles.keys()); + } + + public async read(path: RelativePath): Promise { + const file = this.localFiles.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + return file; + } + + public async getFileSize(path: RelativePath): Promise { + return (await this.read(path)).length; + } + + public async exists(path: RelativePath): Promise { + return this.localFiles.has(path); + } + + public async write(path: RelativePath, content: Uint8Array): Promise { + // This is called by SyncClient to write files received from the server. + // Do NOT call sync methods here - that would create a feedback loop. + this.localFiles.set(path, content); + } + + public async createDirectory(_path: RelativePath): Promise { + // Virtual FS doesn't need directories + } + + public async atomicUpdateText( + path: RelativePath, + updater: (currentContent: TextWithCursors) => TextWithCursors + ): Promise { + // This is called by SyncClient (via FileOperations.write) during merge handling. + // Do NOT call sync methods here - that would create a deadlock. + const file = this.localFiles.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + const currentContent = new TextDecoder().decode(file); + const newContent = updater({ text: currentContent, cursors: [] }).text; + this.localFiles.set(path, new TextEncoder().encode(newContent)); + return newContent; + } + + public async delete(path: RelativePath): Promise { + // This is called by SyncClient to delete files. + // Do NOT call sync methods here - that would create a feedback loop. + this.localFiles.delete(path); + } + + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + // This is called by SyncClient to rename files. + // Do NOT call sync methods here - that would create a feedback loop. + const file = this.localFiles.get(oldPath); + if (!file) { + throw new Error(`File ${oldPath} does not exist`); + } + this.localFiles.set(newPath, file); + if (oldPath !== newPath) { + this.localFiles.delete(oldPath); + } + } + + // Test operations + public async createFile(path: string, content: string): Promise { + this.log(`Creating file ${path} with content: ${content}`); + if (this.localFiles.has(path)) { + throw new Error(`File ${path} already exists`); + } + const contentBytes = new TextEncoder().encode(content); + this.localFiles.set(path, contentBytes); + + // Only sync if enabled - otherwise scheduleSyncForOfflineChanges will pick it up + if (this.isSyncEnabled) { + await this.client.syncLocallyCreatedFile(path); + } + } + + public async updateFile(path: string, content: string): Promise { + this.log(`Updating file ${path} with content: ${content}`); + const contentBytes = new TextEncoder().encode(content); + this.localFiles.set(path, contentBytes); + + // Only sync if enabled + if (this.isSyncEnabled) { + await this.client.syncLocallyUpdatedFile({ relativePath: path }); + } + } + + public async renameFile(oldPath: string, newPath: string): Promise { + this.log(`Renaming file ${oldPath} to ${newPath}`); + // Update local state + const file = this.localFiles.get(oldPath); + if (!file) { + throw new Error(`File ${oldPath} does not exist`); + } + this.localFiles.set(newPath, file); + if (oldPath !== newPath) { + this.localFiles.delete(oldPath); + } + // Only sync if enabled + if (this.isSyncEnabled) { + await this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); + } + } + + public async deleteFile(path: string): Promise { + this.log(`Deleting file ${path}`); + // Update local state + this.localFiles.delete(path); + // Only sync if enabled + if (this.isSyncEnabled) { + await this.client.syncLocallyDeletedFile(path); + } + } + + public async waitForSync(): Promise { + this.log("Waiting for sync to complete..."); + await this.client.waitUntilFinished(); + this.log("Sync complete"); + } + + public async disableSync(): Promise { + this.log("Disabling sync"); + this.isSyncEnabled = false; + await this.client.setSetting("isSyncEnabled", false); + } + + public async enableSync(): Promise { + this.log("Enabling sync"); + this.isSyncEnabled = true; + await this.client.setSetting("isSyncEnabled", true); + } + + public async assertContent( + path: string, + expectedContent: string + ): Promise { + this.log(`Asserting content of ${path} equals "${expectedContent}"`); + const exists = await this.exists(path); + assert( + exists, + `File ${path} does not exist on client ${this.clientId}` + ); + + const actualBytes = await this.read(path); + const actualContent = new TextDecoder().decode(actualBytes); + assert( + actualContent === expectedContent, + `Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"` + ); + this.log(`✓ Content assertion passed for ${path}`); + } + + public async assertExists(path: string): Promise { + this.log(`Asserting ${path} exists`); + const exists = await this.exists(path); + assert( + exists, + `File ${path} does not exist on client ${this.clientId}` + ); + this.log(`✓ File ${path} exists`); + } + + public async assertNotExists(path: string): Promise { + this.log(`Asserting ${path} does not exist`); + const exists = await this.exists(path); + assert( + !exists, + `File ${path} exists on client ${this.clientId} but should not` + ); + this.log(`✓ File ${path} does not exist`); + } + + public async getFiles(): Promise { + return this.listFilesRecursively(); + } + + public async getFileContent(path: string): Promise { + const bytes = await this.read(path); + return new TextDecoder().decode(bytes); + } + + public async cleanup(): Promise { + this.log("Cleaning up..."); + await this.client.waitUntilFinished(); + await this.client.destroy(); + this.log("Cleanup complete"); + } + + private log(message: string): void { + this.logger(`[Client ${this.clientId}] ${message}`); + } +} diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts new file mode 100644 index 00000000..9c15b7e8 --- /dev/null +++ b/frontend/deterministic-tests/src/server-control.ts @@ -0,0 +1,148 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { sleep } from "./utils/sleep"; + +export class ServerControl { + private process: ChildProcess | null = null; + private readonly serverPath: string; + private readonly configPath: string; + + public constructor(serverPath: string, configPath: string) { + this.serverPath = serverPath; + this.configPath = configPath; + } + + public async start(): Promise { + if (this.process !== null) { + throw new Error("Server is already running"); + } + + console.log(`Starting server: ${this.serverPath} ${this.configPath}`); + + let startupError: string | null = null; + + this.process = spawn(this.serverPath, [this.configPath], { + stdio: ["ignore", "pipe", "pipe"], + detached: false + }); + + this.process.stdout?.on("data", (data: Buffer) => { + console.log(`[SERVER] ${data.toString().trim()}`); + }); + + this.process.stderr?.on("data", (data: Buffer) => { + const msg = data.toString().trim(); + console.error(`[SERVER ERROR] ${msg}`); + // Capture startup errors + if (msg.includes("Failed to") || msg.includes("Error")) { + startupError = msg; + } + }); + + this.process.on("error", (err) => { + console.error("[SERVER] Process error:", err); + startupError = err.message; + }); + + this.process.on("exit", (code, signal) => { + console.log(`[SERVER] Exited with code ${code}, signal ${signal}`); + this.process = null; + }); + + // Give the process a moment to fail if it's going to + await sleep(100); + + // Check if process died during startup (exit handler sets this.process to null) + this.checkProcessAlive(startupError, "startup"); + + // Wait for server to be ready + await this.waitForReady(); + + // Final check that our process is still the one running + this.checkProcessAlive(startupError, "after startup"); + } + + public async waitForReady(maxAttempts = 30): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const response = await fetch( + "http://localhost:3000/vaults/test/ping" + ); + if (response.ok) { + console.log("[SERVER] Ready"); + return; + } + } catch { + // Server not ready yet + } + await sleep(100); + } + throw new Error("Server failed to start within timeout"); + } + + public pause(): void { + if (this.process?.pid === undefined) { + throw new Error("Server is not running"); + } + console.log("[SERVER] Pausing..."); + process.kill(this.process.pid, "SIGSTOP"); + } + + public resume(): void { + if (this.process?.pid === undefined) { + throw new Error("Server is not running"); + } + console.log("[SERVER] Resuming..."); + process.kill(this.process.pid, "SIGCONT"); + } + + public async stop(): Promise { + if (this.process?.pid === undefined) { + return; + } + + console.log("[SERVER] Stopping..."); + const { pid } = this.process; + + return new Promise((resolve) => { + if (this.process === null) { + resolve(); + return; + } + + this.process.on("exit", () => { + resolve(); + }); + + // Try graceful shutdown first + process.kill(pid, "SIGTERM"); + + // Force kill after 5 seconds + setTimeout(() => { + if (this.process?.pid !== undefined) { + process.kill(this.process.pid, "SIGKILL"); + } + }, 5000); + }); + } + + public isRunning(): boolean { + return this.process?.pid !== undefined; + } + + private checkProcessAlive( + startupError: string | null, + phase: string + ): void { + const proc = this.process; + if (proc === null) { + throw new Error( + `Server process died during ${phase}: ${startupError ?? "unknown error"}` + ); + } + if (proc.exitCode !== null) { + throw new Error( + `Server process exited during ${phase}: ${startupError ?? "unknown error"}` + ); + } + } +} diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts new file mode 100644 index 00000000..0968f3a4 --- /dev/null +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -0,0 +1,35 @@ +/** + * Deterministic test framework for VaultLink sync testing. + * Allows defining exact sequences of operations to test specific scenarios. + */ + +export type TestStep = + | { type: "create"; client: number; path: string; content: string } + | { type: "update"; client: number; path: string; content: string } + | { type: "rename"; client: number; oldPath: string; newPath: string } + | { type: "delete"; client: number; path: string } + | { type: "sync"; client?: number } // wait for sync (specific client or all if undefined) + | { type: "disable-sync"; client: number } + | { type: "enable-sync"; client: number } + | { type: "wait"; duration: number } // wait N milliseconds + | { type: "pause-server" } + | { type: "resume-server" } + | { type: "barrier" } // wait for all clients to finish pending operations + | { type: "assert-content"; client: number; path: string; content: string } + | { type: "assert-exists"; client: number; path: string } + | { type: "assert-not-exists"; client: number; path: string } + | { type: "assert-consistent" }; // all clients have same files and content + +export interface TestDefinition { + name: string; + description?: string; + clients: number; + steps: TestStep[]; +} + +export interface TestResult { + success: boolean; + error?: string; + stepsFailed?: number; + duration: number; +} diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts new file mode 100644 index 00000000..570c7de1 --- /dev/null +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -0,0 +1,292 @@ +import type { TestDefinition, TestResult, TestStep } from "./test-definition"; +import { DeterministicAgent } from "./deterministic-agent"; +import type { ServerControl } from "./server-control"; +import type { SyncSettings } from "sync-client"; +import { utils } from "sync-client"; +import { sleep } from "./utils/sleep"; +import { assert } from "./utils/assert"; +import WebSocket from "ws"; +import { randomUUID } from "node:crypto"; + +export class TestRunner { + private agents: DeterministicAgent[] = []; + private readonly serverControl: ServerControl; + private readonly token: string; + private readonly remoteUri: string; + private readonly logBuffer: string[] = []; + + public constructor( + serverControl: ServerControl, + options: { + token?: string; + remoteUri?: string; + } = {} + ) { + this.serverControl = serverControl; + this.token = options.token ?? "test-token-change-me "; + this.remoteUri = options.remoteUri ?? "http://localhost:3000"; + } + + public async runTest(test: TestDefinition): Promise { + const startTime = Date.now(); + this.log(`\n${"=".repeat(80)}`); + this.log(`Running test: ${test.name}`); + if (test.description !== undefined && test.description !== "") { + this.log(`Description: ${test.description}`); + } + this.log(`Clients: ${test.clients}`); + this.log(`Steps: ${test.steps.length}`); + this.log("=".repeat(80)); + + try { + // Initialize agents + await this.initializeAgents(test.clients); + + // Execute steps + for (let i = 0; i < test.steps.length; i++) { + const step = test.steps[i]; + this.log( + `\nStep ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}` + ); + await this.executeStep(step); + } + + // Cleanup + await this.cleanup(); + + const duration = Date.now() - startTime; + this.log(`\n✓ Test passed: ${test.name} (${duration}ms)`); + + return { + success: true, + duration + }; + } catch (error) { + const duration = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); + this.log(`\n✗ Test failed: ${test.name}`); + this.log(`Error: ${errorMessage}`); + + await this.cleanup(); + + return { + success: false, + error: errorMessage, + duration + }; + } + } + + public getLog(): string { + return this.logBuffer.join("\n"); + } + + private log(message: string): void { + const timestamp = new Date().toISOString(); + const logLine = `[${timestamp}] ${message}`; + console.log(logLine); + this.logBuffer.push(logLine); + } + + private async initializeAgents(count: number): Promise { + // Use unique vault name for each test run to avoid data interference + const vaultName = `test-${randomUUID()}`; + this.log(`\nInitializing ${count} agents with vault: ${vaultName}`); + + const settings: Partial = { + // Start with sync disabled to avoid scheduleSyncForOfflineChanges running + // before we've created our test files. Tests must explicitly enable sync. + isSyncEnabled: false, + token: this.token, + vaultName, + syncConcurrency: 1, + remoteUri: this.remoteUri + }; + + for (let i = 0; i < count; i++) { + const agent = new DeterministicAgent(i, settings, (msg) => { + this.log(msg); + }); + // WebSocket from 'ws' package needs type assertion for browser WebSocket interface + + await agent.init( + fetch, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + WebSocket as unknown as typeof globalThis.WebSocket + ); + this.agents.push(agent); + this.log(`Initialized client ${i}`); + } + + // Wait for WebSocket connections to fully establish + await sleep(100); + this.log("All agents initialized and connected"); + // Note: Sync is disabled on all agents. Tests must explicitly enable sync. + } + + private async executeStep(step: TestStep): Promise { + switch (step.type) { + case "create": + await this.agents[step.client].createFile( + step.path, + step.content + ); + break; + + case "update": + await this.agents[step.client].updateFile( + step.path, + step.content + ); + break; + + case "rename": + await this.agents[step.client].renameFile( + step.oldPath, + step.newPath + ); + break; + + case "delete": + await this.agents[step.client].deleteFile(step.path); + break; + + case "sync": + if (step.client !== undefined) { + await this.agents[step.client].waitForSync(); + } else { + // Wait for all clients + for (const agent of this.agents) { + await agent.waitForSync(); + } + } + break; + + case "disable-sync": + await this.agents[step.client].disableSync(); + break; + + case "enable-sync": + await this.agents[step.client].enableSync(); + break; + + case "wait": + this.log(`Waiting ${step.duration}ms...`); + await sleep(step.duration); + break; + + case "pause-server": + this.serverControl.pause(); + break; + + case "resume-server": + this.serverControl.resume(); + break; + + case "barrier": + this.log( + "Barrier: waiting for all clients to finish pending operations..." + ); + // First, wait for all local pending operations to complete + for (const agent of this.agents) { + await agent.waitForSync(); + } + + // Wait for network propagation + await sleep(500); + + // Then sync again to ensure all clients have received updates from others + for (const agent of this.agents) { + await agent.waitForSync(); + } + this.log("Barrier complete"); + break; + + case "assert-content": + await this.agents[step.client].assertContent( + step.path, + step.content + ); + break; + + case "assert-exists": + await this.agents[step.client].assertExists(step.path); + break; + + case "assert-not-exists": + await this.agents[step.client].assertNotExists(step.path); + break; + + case "assert-consistent": + await this.assertConsistent(); + break; + + default: { + const unknownStep = step as { type: string }; + throw new Error(`Unknown step type: ${unknownStep.type}`); + } + } + } + + private async assertConsistent(): Promise { + this.log("Asserting all clients are consistent..."); + + if (this.agents.length < 2) { + this.log("Only one client, skipping consistency check"); + return; + } + + const [referenceAgent] = this.agents; + const referenceFiles = (await referenceAgent.getFiles()).sort(); + + this.log( + `Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}` + ); + + for (let i = 1; i < this.agents.length; i++) { + const agent = this.agents[i]; + const files = (await agent.getFiles()).sort(); + + this.log( + `Client ${i} has ${files.length} files: ${files.join(", ")}` + ); + + // Check file lists match + assert( + files.length === referenceFiles.length, + `File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files` + ); + + for (let j = 0; j < files.length; j++) { + assert( + files[j] === referenceFiles[j], + `File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"` + ); + } + + // Check file contents match + for (const file of referenceFiles) { + const referenceContent = + await referenceAgent.getFileContent(file); + const agentContent = await agent.getFileContent(file); + + assert( + referenceContent === agentContent, + `Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"` + ); + } + } + + this.log("✓ All clients are consistent"); + } + + private async cleanup(): Promise { + this.log("\nCleaning up agents..."); + for (const agent of this.agents) { + await agent.cleanup(); + } + this.agents = []; + this.log("Cleanup complete"); + } +} diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts new file mode 100644 index 00000000..ecb33ec6 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -0,0 +1,68 @@ +import type { TestDefinition } from "../test-definition"; + +/** + * Rename-Create Conflict Test + * + * Scenario: + * - Client 0 creates file A with content "hi" and syncs it + * - Client 1 syncs (now has A with "hi") + * - Client 0 disables sync (disconnects WebSocket) + * - Client 1 renames A to B and syncs + * - Client 0 (offline, unaware of the rename) creates file B with content "hi" + * - Client 0 enables sync again + * - Both clients sync + * + * Expected behavior: + * - The system must resolve the conflict deterministically + * - Client 0's create of B conflicts with Client 1's rename of A to B + * - Possible resolutions: + * 1. One file wins (B contains one version) + * 2. Files are merged/renamed to avoid collision + * 3. One operation is rejected + * - Both clients must converge to a consistent state + */ +export const renameCreateConflictTest: TestDefinition = { + name: "Rename-Create Conflict", + description: + "Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " + + "The system must resolve the conflict deterministically.", + clients: 2, + steps: [ + // Enable sync on all clients first (agents start with sync disabled) + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + // Client 0 creates file A with "hi" and syncs + { type: "create", client: 0, path: "A.md", content: "hi" }, + { type: "sync", client: 0 }, + + // Client 1 syncs to get file A + { type: "sync", client: 1 }, + { type: "assert-exists", client: 1, path: "A.md" }, + { type: "assert-content", client: 1, path: "A.md", content: "hi" }, + + // IMPORTANT: Disable sync on Client 0 BEFORE Client 1 renames + // This ensures Client 0 doesn't receive the rename notification via WebSocket + { type: "disable-sync", client: 0 }, + + // Client 1 renames A to B and syncs + { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, + { type: "sync", client: 1 }, + + // Client 0 creates B (without knowing about the rename, since sync is disabled) + { type: "create", client: 0, path: "B.md", content: "hi" }, + + // Now enable sync on Client 0 and let conflict resolution happen + { type: "enable-sync", client: 0 }, + + { type: "barrier" }, // Wait for conflict resolution + + // Give system time to propagate + { type: "wait", duration: 500 }, + + { type: "barrier" }, + + // Verify both clients converge to the same state + { type: "assert-consistent" } + ] +}; diff --git a/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts b/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts new file mode 100644 index 00000000..c5d0ddd0 --- /dev/null +++ b/frontend/deterministic-tests/src/tests/write-write-conflict.test.ts @@ -0,0 +1,46 @@ +import type { TestDefinition } from "../test-definition"; + +/** + * Write/Write Conflict Test + * + * Scenario: + * - Client 0 creates file A with content "hello" + * - Client 1 creates file A with content "world" + * - Both clients sync + * - The system must resolve the conflict deterministically + * + * Expected behavior: + * - One version wins (typically last-write-wins or version-based) + * - Both clients converge to the same final state + */ +export const writeWriteConflictTest: TestDefinition = { + name: "Write/Write Conflict", + description: + "Two clients simultaneously create the same file with different content. " + + "The system should resolve the conflict and both clients should converge.", + clients: 2, + steps: [ + // Both clients go offline + { type: "disable-sync", client: 0 }, + { type: "disable-sync", client: 1 }, + + // Both clients create the same file with different content + { type: "create", client: 0, path: "A.md", content: "hello" }, + { type: "create", client: 1, path: "A.md", content: "world" }, + + // Enable sync and wait for conflict resolution + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + + // Wait for sync to complete and propagate + { type: "barrier" }, + + // Extra time for any conflict resolution + { type: "wait", duration: 300 }, + + { type: "barrier" }, + + // Verify both clients have the same file(s) and content + { type: "assert-consistent" } + ] +}; diff --git a/frontend/deterministic-tests/src/utils/assert.ts b/frontend/deterministic-tests/src/utils/assert.ts new file mode 100644 index 00000000..4e709060 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/assert.ts @@ -0,0 +1,5 @@ +export function assert(value: boolean, message: string): asserts value { + if (!value) { + throw new Error(message); + } +} diff --git a/frontend/deterministic-tests/src/utils/sleep.ts b/frontend/deterministic-tests/src/utils/sleep.ts new file mode 100644 index 00000000..ff474799 --- /dev/null +++ b/frontend/deterministic-tests/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/frontend/deterministic-tests/tsconfig.json b/frontend/deterministic-tests/tsconfig.json new file mode 100644 index 00000000..7558871d --- /dev/null +++ b/frontend/deterministic-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "strict": true, + "target": "ES2022", + "module": "CommonJS", + "esModuleInterop": true, + "lib": ["DOM", "ES2024"], + "moduleResolution": "node" + }, + "exclude": ["./dist"] +} diff --git a/frontend/deterministic-tests/webpack.config.js b/frontend/deterministic-tests/webpack.config.js new file mode 100644 index 00000000..6aee1547 --- /dev/null +++ b/frontend/deterministic-tests/webpack.config.js @@ -0,0 +1,30 @@ +const path = require("path"); +const webpack = require("webpack"); + +module.exports = { + entry: "./src/cli.ts", + target: "node", + mode: "production", + optimization: { + minimize: false + }, + module: { + rules: [ + { + test: /\.ts$/, + use: "ts-loader" + } + ] + }, + resolve: { + extensions: [".ts", ".js"] + }, + output: { + globalObject: "this", + filename: "cli.js", + path: path.resolve(__dirname, "dist") + }, + plugins: [ + new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true }) + ] +}; diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 4af350f4..9ad4d2a1 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -135,9 +135,9 @@ export default class VaultLinkPlugin extends Plugin { nativeLineEndings: Platform.isWin ? "\r\n" : "\n", ...(IS_DEBUG_BUILD ? { - fetch: debugging.slowFetchFactory(1), - webSocket: debugging.slowWebSocketFactory(1, new Logger()) - } + fetch: debugging.slowFetchFactory(1), + webSocket: debugging.slowWebSocketFactory(1, new Logger()) + } : {}) }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e9460f22..56aee4f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "sync-client", "obsidian-plugin", "test-client", + "deterministic-tests", "local-client-cli" ], "devDependencies": { @@ -20,6 +21,23 @@ "typescript-eslint": "8.49.0" } }, + "deterministic-tests": { + "version": "0.14.0", + "bin": { + "deterministic-tests": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^25.0.2", + "@types/ws": "^8.5.13", + "sync-client": "file:../sync-client", + "ts-loader": "^9.5.4", + "tslib": "2.8.1", + "typescript": "5.9.3", + "webpack": "^5.103.0", + "webpack-cli": "^6.0.1", + "ws": "^8.18.0" + } + }, "local-client-cli": { "version": "0.14.0", "bin": { @@ -537,6 +555,15 @@ "@types/estree": "*" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.49.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", @@ -1486,6 +1513,10 @@ "node": ">=0.10" } }, + "node_modules/deterministic-tests": { + "resolved": "deterministic-tests", + "link": true + }, "node_modules/dettle": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", @@ -4080,6 +4111,27 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 37961661..840de2a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "sync-client", "obsidian-plugin", "test-client", + "deterministic-tests", "local-client-cli" ], "prettier": { @@ -29,7 +30,7 @@ "build": "npm run build --workspaces", "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "test": "npm run test --workspaces", - "lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", + "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"", "update": "ncu -u -ws" }, "devDependencies": { diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 5118833f..6cd53504 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -103,7 +103,7 @@ export class Database { i === 0 ? false : records[i - 1].parallelVersion === - current.parallelVersion + current.parallelVersion ) ) { throw new Error( @@ -350,7 +350,7 @@ export class Database { if (duplicates.length > 0) { throw new Error( "Document IDs are not unique, found duplicates: " + - duplicates.join("; ") + duplicates.join("; ") ); } } diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 99ee79ad..647ac8da 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -67,7 +67,7 @@ export class SyncService { public async create({ relativePath, - contentBytes, + contentBytes }: { relativePath: RelativePath; contentBytes: Uint8Array; @@ -151,7 +151,8 @@ export class SyncService { (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug( - `Updated document ${JSON.stringify(result)} with id ${result.documentId + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId }}` ); @@ -203,7 +204,8 @@ export class SyncService { (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug( - `Updated document ${JSON.stringify(result)} with id ${result.documentId + `Updated document ${JSON.stringify(result)} with id ${ + result.documentId }}` ); @@ -330,7 +332,7 @@ export class SyncService { return this.retryForever(async () => { this.logger.debug( "Getting all documents" + - (since != null ? ` since ${since}` : "") + (since != null ? ` since ${since}` : "") ); const url = new URL(this.getUrl("/documents")); diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 6de30bd8..17103be5 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -2,6 +2,5 @@ export interface CreateDocumentVersion { relative_path: string; - force_merge: boolean | null; content: number[]; } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 23989dfc..1d3b3c6b 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -56,7 +56,7 @@ export class SyncClient { database: Partial; }> > - ) { } + ) {} public get documentCount(): number { return this.database.length; @@ -369,7 +369,7 @@ export class SyncClient { this.checkIfDestroyed("syncLocallyCreatedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); - return this.syncer.syncLocallyCreatedFile(relativePath,); + return this.syncer.syncLocallyCreatedFile(relativePath); } public async syncLocallyDeletedFile( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 31efce0e..0b85601e 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -81,7 +81,7 @@ export class Syncer { } public async syncLocallyCreatedFile( - relativePath: RelativePath, + relativePath: RelativePath ): Promise { if ( this.database.getLatestDocumentByRelativePath(relativePath) @@ -96,7 +96,6 @@ export class Syncer { } const [promise, resolve, reject] = createPromise(); - this.logger.warn(`creating ${relativePath} locally`); const document = this.database.createNewPendingDocument( relativePath, @@ -106,12 +105,9 @@ export class Syncer { try { await this.syncQueue.add(async () => this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile( - { document, forceMerge } + { document } ) - ) - - this.logger.warn(`done creating ${relativePath} locally`); - + ); resolve(); } catch (e) { @@ -149,7 +145,9 @@ export class Syncer { try { await this.syncQueue.add(async () => - this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile(document) + this.unrestrictedSyncer.unrestrictedSyncLocallyDeletedFile( + document + ) ); resolve(); @@ -174,7 +172,7 @@ export class Syncer { // in that case, we mustn't move it again. if ( this.database.getLatestDocumentByRelativePath(relativePath) === - undefined || + undefined || this.database.getLatestDocumentByRelativePath(relativePath) ?.isDeleted === true ) { @@ -191,8 +189,6 @@ export class Syncer { let document = this.database.getLatestDocumentByRelativePath(relativePath); - this.logger.warn(`sync doc ${JSON.stringify(document)} for path ${relativePath} (old path: ${oldPath}), len docs: ${document?.updates.length}`); - if ( oldPath !== undefined && document?.metadata?.remoteRelativePath === relativePath @@ -224,14 +220,15 @@ export class Syncer { relativePath, promise ); - this.logger.warn(`updating ${document.relativePath} locally`); try { await this.syncQueue.add(async () => - this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile({ - oldPath, - document - }) + this.unrestrictedSyncer.unrestrictedSyncLocallyCreatedOrUpdatedFile( + { + oldPath, + document + } + ) ); resolve(); @@ -324,45 +321,37 @@ export class Syncer { remoteVersion.documentId ); - this.logger.warn(`${remoteVersion.documentId} got remote update ${JSON.stringify(remoteVersion)}`); - if (document === undefined) { - this.logger.warn(`${remoteVersion.documentId} but document doesn't exist`) - - return this.remoteDocumentsLock.withLock( // Avoid the same documents getting created in parallel multiple times through fetching multiple updates of the same // new remote document concurrently. // There might be multiple tasks waiting for the lock remoteVersion.documentId, async () => { - // We have to wait for any ongoing creates sent for this file to finish, // This is to avoid fetching one's own creates before the corresponding local create has finished syncing. This is a concern because - // documents being created don't yet have a document id in the local database and we could be notified of the remote create - // before the local create has finished syncing, so we can't just ignore the update based on the local DB content as we - // can't find the corresponding document yet. + // documents being created don't yet have a document id in the local database and we could be notified of the remote create + // before the local create has finished syncing, so we can't just ignore the update based on the local DB content as we + // can't find the corresponding document yet. if (document?.metadata === undefined) { - await this.unrestrictedSyncer.fileCreationLock.waitForLockWithoutAcquiringLock(remoteVersion.relativePath); + await this.unrestrictedSyncer.fileCreationLock.waitForLockWithoutAcquiringLock( + remoteVersion.relativePath + ); } document = this.database.getDocumentByDocumentId( remoteVersion.documentId ); - this.logger.warn(`${remoteVersion.documentId} rechecking, document is now ${JSON.stringify(document)}`) - // We're the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` if (document === undefined) { - this.logger.warn(`${remoteVersion.documentId} document is undefined, creating new document`) await this.syncQueue.add(async () => this.unrestrictedSyncer.unrestrictedSyncRemotelyUpdatedFile( remoteVersion ) ); } else { - const [promise, resolve, reject] = - createPromise(); + const [promise, resolve, reject] = createPromise(); document = await this.database.getResolvedDocumentByRelativePath( @@ -382,19 +371,13 @@ export class Syncer { } catch (e) { reject(e); } finally { - this.database.removeDocumentPromise( - promise - ); + this.database.removeDocumentPromise(promise); } } - this.database.addSeenUpdateId( - remoteVersion.vaultUpdateId - ); + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); } - ) - } else { - this.logger.warn(`${remoteVersion.documentId} and document exists (path: ${JSON.stringify(document)})`); + ); } // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` @@ -440,7 +423,11 @@ export class Syncer { } } - interface Instruction { "type": "update" | "create", relativePath: string, oldPath?: string } + interface Instruction { + type: "update" | "create"; + relativePath: string; + oldPath?: string; + } const instructions: (Instruction | undefined)[] = await awaitAll( allLocalFiles.map(async (relativePath) => { if ( @@ -499,7 +486,6 @@ export class Syncer { oldPath: originalFile.relativePath, relativePath } as Instruction; - } this.logger.debug( @@ -513,7 +499,6 @@ export class Syncer { }) ); - // this has to happen strictly after the previous awaitAll, as that one // might have removed some of the documents from the list await awaitAll( @@ -527,35 +512,38 @@ export class Syncer { }) ); + await awaitAll( + instructions.map(async (instruction) => { + if (instruction === undefined) { + return; + } - await awaitAll(instructions.map(async (instruction) => { - if (instruction === undefined) { - return; - } - - if (instruction.type === "update") { - // We're outside of the pqueue, so we need to call the public wrapper - await this.syncLocallyUpdatedFile({ - oldPath: instruction.oldPath, - relativePath: instruction.relativePath - }); return; - } - })); + if (instruction.type === "update") { + // We're outside of the pqueue, so we need to call the public wrapper + await this.syncLocallyUpdatedFile({ + oldPath: instruction.oldPath, + relativePath: instruction.relativePath + }); + return; + } + }) + ); // we have to ensure the deletes & updates have finished before starting creates, // otherwise the server might return an existing document (that we're about to delete) // instead of actually creating a new one - await awaitAll(instructions.map(async (instruction) => { - if (instruction === undefined) { - return; - } - - if (instruction.type === "create") { - // We're outside of the pqueue, so we need to call the public wrapper - await this.syncLocallyCreatedFile(instruction.relativePath,); return; - } - })); - + await awaitAll( + instructions.map(async (instruction) => { + if (instruction === undefined) { + return; + } + if (instruction.type === "create") { + // We're outside of the pqueue, so we need to call the public wrapper + await this.syncLocallyCreatedFile(instruction.relativePath); + return; + } + }) + ); } } diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts index f29e19c8..b6add795 100644 --- a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -36,9 +36,9 @@ import type { ServerConfig } from "../services/server-config"; import { Locks } from "../utils/data-structures/locks"; export class UnrestrictedSyncer { + public readonly fileCreationLock: Locks = + new Locks(); private ignorePatterns: RegExp[]; - public readonly fileCreationLock: Locks = new Locks(); - public constructor( private readonly logger: Logger, @@ -74,32 +74,31 @@ export class UnrestrictedSyncer { force?: boolean; document: DocumentRecord; }): Promise { - // this.history.addHistoryEntry({ // status: SyncStatus.SUCCESS, // details: updateDetails, // message: `Successfully uploaded locally created file` // }); - let updateDetails: SyncCreateDetails | SyncUpdateDetails | SyncMovedDetails; - if (document.metadata === undefined) { - updateDetails = { - type: SyncType.CREATE, - relativePath: document.relativePath - }; - } - else if (oldPath !== undefined) { - updateDetails = { - type: SyncType.MOVE, - relativePath: document.relativePath, - movedFrom: oldPath - }; - } else { - updateDetails = { - type: SyncType.UPDATE, - relativePath: document.relativePath - }; - } + const updateDetails: + | SyncCreateDetails + | SyncUpdateDetails + | SyncMovedDetails = + document.metadata === undefined + ? { + type: SyncType.CREATE, + relativePath: document.relativePath + } + : oldPath !== undefined + ? { + type: SyncType.MOVE, + relativePath: document.relativePath, + movedFrom: oldPath + } + : { + type: SyncType.UPDATE, + relativePath: document.relativePath + }; await this.executeSync(updateDetails, async () => { const originalRelativePath = document.relativePath; @@ -116,31 +115,33 @@ export class UnrestrictedSyncer { ); // this can throw FileNotFoundError const contentHash = hash(contentBytes); - this.logger.warn(`updating ${document.relativePath} locally, inner`); - let response: DocumentVersion | DocumentUpdateResponse | undefined = undefined; if (document.metadata === undefined) { - response = await this.fileCreationLock.withLock(document.relativePath, async () => { - const response = await this.syncService.create({ - relativePath: originalRelativePath, - contentBytes, - }); + response = await this.fileCreationLock.withLock( + document.relativePath, + async () => { + const createResponse = await this.syncService.create({ + relativePath: originalRelativePath, + contentBytes + }); - await this.handleMaybeMergingResponse({ - document, - response, - contentHash, - originalRelativePath, - originalContentBytes: contentBytes - }); + await this.handleMaybeMergingResponse({ + document, + response: createResponse, + contentHash, + originalRelativePath, + originalContentBytes: contentBytes + }); - return response; - }); + return createResponse; + } + ); } else { const areThereLocalChanges = - document.metadata.hash !== contentHash || oldPath !== undefined; + document.metadata.hash !== contentHash || + oldPath !== undefined; if (areThereLocalChanges) { const isText = @@ -157,22 +158,22 @@ export class UnrestrictedSyncer { response = isText && cachedVersion !== undefined ? await this.syncService.putText({ - documentId: document.metadata.documentId, - parentVersionId: - document.metadata.parentVersionId, - relativePath: document.relativePath, - content: diff( - new TextDecoder().decode(cachedVersion), - new TextDecoder().decode(contentBytes) - ) - }) + documentId: document.metadata.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + content: diff( + new TextDecoder().decode(cachedVersion), + new TextDecoder().decode(contentBytes) + ) + }) : await this.syncService.putBinary({ - documentId: document.metadata.documentId, - parentVersionId: - document.metadata.parentVersionId, - relativePath: document.relativePath, - contentBytes - }); + documentId: document.metadata.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + contentBytes + }); } else { if (!force) { this.logger.debug( @@ -196,8 +197,6 @@ export class UnrestrictedSyncer { }); } - - if (!("type" in response) || response.type === "MergingUpdate") { if (!force) { this.history.addHistoryEntry({ @@ -211,16 +210,16 @@ export class UnrestrictedSyncer { const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = oldPath !== undefined || - response.relativePath != originalRelativePath + response.relativePath != originalRelativePath ? { - type: SyncType.MOVE, - relativePath: response.relativePath, - movedFrom: originalRelativePath - } + type: SyncType.MOVE, + relativePath: response.relativePath, + movedFrom: originalRelativePath + } : { - type: SyncType.UPDATE, - relativePath: response.relativePath - }; + type: SyncType.UPDATE, + relativePath: response.relativePath + }; // if (areThereLocalChanges) { // this.history.addHistoryEntry({ @@ -229,7 +228,7 @@ export class UnrestrictedSyncer { // message: `Successfully uploaded locally updated file to the server`, // author: response.userId // }); - // } else + // } else if (!response.isDeleted) { this.history.addHistoryEntry({ @@ -255,7 +254,6 @@ export class UnrestrictedSyncer { }); } - public async unrestrictedSyncLocallyDeletedFile( document: DocumentRecord ): Promise { @@ -307,7 +305,6 @@ export class UnrestrictedSyncer { relativePath: remoteVersion.relativePath }; - await this.executeSync(updateDetails, async () => { if (document?.metadata !== undefined) { // If the file exists locally, let's pretend the user has updated it @@ -474,8 +471,6 @@ export class UnrestrictedSyncer { } } - - private async handleMaybeMergingResponse({ document, response, @@ -584,8 +579,9 @@ export class UnrestrictedSyncer { type: SyncType.SKIPPED, relativePath }, - message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB - } MB` + message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ + maxFileSizeMB + } MB` }; } } diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 1e550c5a..f0f79a46 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -18,7 +18,7 @@ export class Locks { [() => unknown, (err: unknown) => unknown][] >(); - public constructor(private readonly logger?: Logger) { } + public constructor(private readonly logger?: Logger) {} /** * Executes a function while holding exclusive locks on one or more keys. diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 5ca85f2a..67368303 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -63,7 +63,10 @@ export class MockAgent extends MockClient { case LogLevel.ERROR: console.error(formatted); - if (!this.useSlowFileEvents && !formatted.includes("retrying in")) { + if ( + !this.useSlowFileEvents && + !formatted.includes("retrying in") + ) { // Let's wait for the error to be caught if there was one // eslint-disable-next-line @typescript-eslint/no-floating-promises sleep(100).then(() => { @@ -227,14 +230,14 @@ export class MockAgent extends MockClient { ); this.client.logger.info( "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") + Array.from(otherAgent.localFiles.keys()).join(", ") ); otherAgent.client.logger.info( "Local data: " + JSON.stringify(otherAgent.data, null, 2) ); otherAgent.client.logger.info( "Local files: " + - Array.from(otherAgent.localFiles.keys()).join(", ") + Array.from(otherAgent.localFiles.keys()).join(", ") ); throw e; @@ -307,7 +310,9 @@ export class MockAgent extends MockClient { `Decided to create file ${file} with content ${content}` ); - return this.create(file, new TextEncoder().encode(` ${content} `), { ignoreSlowFileEvents: true }); + return this.create(file, new TextEncoder().encode(` ${content} `), { + ignoreSlowFileEvents: true + }); } private async disableSyncAction(): Promise { @@ -371,10 +376,14 @@ export class MockAgent extends MockClient { `Decided to update file ${file} with ${content}` ); this.doNotTouchWhileOffline.push(file); - await this.atomicUpdateText(file, (old) => ({ - text: old.text + ` ${content} `, - cursors: [] - }), { ignoreSlowFileEvents: true }); + await this.atomicUpdateText( + file, + (old) => ({ + text: old.text + ` ${content} `, + cursors: [] + }), + { ignoreSlowFileEvents: true } + ); } private async deleteFileAction(files: RelativePath[]): Promise { diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 94cee762..9f8cc18a 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -65,7 +65,9 @@ export class MockClient implements FileSystemOperations { public async create( path: RelativePath, newContent: Uint8Array, - { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } + { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { + ignoreSlowFileEvents: false + } ): Promise { if (this.localFiles.has(path)) { throw new Error(`File ${path} already exists`); @@ -75,9 +77,10 @@ export class MockClient implements FileSystemOperations { ); this.localFiles.set(path, newContent); - this.executeFileOperation((async () => - this.client.syncLocallyCreatedFile(path) - ), ignoreSlowFileEvents); + this.executeFileOperation( + async () => this.client.syncLocallyCreatedFile(path), + ignoreSlowFileEvents + ); } public async createDirectory(_path: RelativePath): Promise { @@ -87,7 +90,9 @@ export class MockClient implements FileSystemOperations { public async atomicUpdateText( path: RelativePath, updater: (currentContent: TextWithCursors) => TextWithCursors, - { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } + { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { + ignoreSlowFileEvents: false + } ): Promise { const file = this.localFiles.get(path); if (!file) { @@ -104,13 +109,13 @@ export class MockClient implements FileSystemOperations { .map((part) => part.trim()); const newParts = newContent.split(" ").map((part) => part.trim()); existingParts.forEach((part) => - // all changes should be additive - { - assert( - newParts.includes(part), - `Part ${part} not found in new content: ${newContent}` - ); - } + // all changes should be additive + { + assert( + newParts.includes(part), + `Part ${part} not found in new content: ${newContent}` + ); + } ); } @@ -118,11 +123,13 @@ export class MockClient implements FileSystemOperations { `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` ); - this.executeFileOperation((async () => - this.client.syncLocallyUpdatedFile({ - relativePath: path - }) - ), ignoreSlowFileEvents); + this.executeFileOperation( + async () => + this.client.syncLocallyUpdatedFile({ + relativePath: path + }), + ignoreSlowFileEvents + ); return newContent; } @@ -146,21 +153,29 @@ export class MockClient implements FileSystemOperations { }); } - public async delete(path: RelativePath, { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false }): Promise { + public async delete( + path: RelativePath, + { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { + ignoreSlowFileEvents: false + } + ): Promise { this.client.logger.info( `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` ); this.localFiles.delete(path); - this.executeFileOperation((async () => - this.client.syncLocallyDeletedFile(path) - ), ignoreSlowFileEvents); + this.executeFileOperation( + async () => this.client.syncLocallyDeletedFile(path), + ignoreSlowFileEvents + ); } public async rename( oldPath: RelativePath, newPath: RelativePath, - { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { ignoreSlowFileEvents: false } + { ignoreSlowFileEvents }: { ignoreSlowFileEvents: boolean } = { + ignoreSlowFileEvents: false + } ): Promise { const file = this.localFiles.get(oldPath); if (!file) { @@ -175,15 +190,20 @@ export class MockClient implements FileSystemOperations { `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` ); - this.executeFileOperation((async () => - this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath - }) - ), ignoreSlowFileEvents); + this.executeFileOperation( + async () => + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }), + ignoreSlowFileEvents + ); } - private executeFileOperation(callback: () => unknown, ignoreSlowFileEvents = false): void { + private executeFileOperation( + callback: () => unknown, + ignoreSlowFileEvents = false + ): void { if (this.useSlowFileEvents && !ignoreSlowFileEvents) { // we aren't the best client and it takes some time to notice changes setTimeout(callback, Math.random() * 100); diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index e3fd7000..97484d51 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -37,8 +37,6 @@ async function runTest({ slowFileEvents = useSlowFileEvents; doResets = useResets; - - const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; logger.info(`Running test ${settings}`); @@ -70,7 +68,9 @@ async function runTest({ await utils.awaitAll(clients.map(async (client) => client.init())); for (const client of clients) { - const initialDocCount = Math.floor(Math.random() * MAX_INITIAL_DOCS); + const initialDocCount = Math.floor( + Math.random() * MAX_INITIAL_DOCS + ); if (initialDocCount > 0) { logger.info( `Creating ${initialDocCount} initial documents for ${client.name}` diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index 1f235b01..e9d47559 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -9,24 +9,24 @@ server: max_clients_per_vault: 256 response_timeout: 30m mergeable_file_extensions: - - md - - txt + - md + - txt users: user_configs: - - name: admin - token: test-token-change-me - vault_access: - type: allow_access_to_all - - name: other-admin - token: test-token-change-me2 - vault_access: - type: allow_access_to_all - - name: test - token: other-test-token - vault_access: - type: allow_list - allowed: - - default + - name: admin + token: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default logging: log_directory: logs log_rotation: 7days