From 4872f6d3b3f55073fa3f06dfc4944e1979def502 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 22 Feb 2025 12:56:23 +0000 Subject: [PATCH] WIP test client --- .gitignore | 3 +- frontend/test-client/src/agent/mock-agent.ts | 107 +++++++++++++++ frontend/test-client/src/agent/mock-client.ts | 125 ++++++++++++++++++ frontend/test-client/src/cli.ts | 43 ++++++ frontend/test-client/src/utils/assert.ts | 5 + frontend/test-client/src/utils/choose.ts | 3 + frontend/test-client/src/utils/sleep.ts | 3 + 7 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 frontend/test-client/src/agent/mock-agent.ts create mode 100644 frontend/test-client/src/agent/mock-client.ts create mode 100644 frontend/test-client/src/cli.ts create mode 100644 frontend/test-client/src/utils/assert.ts create mode 100644 frontend/test-client/src/utils/choose.ts create mode 100644 frontend/test-client/src/utils/sleep.ts diff --git a/.gitignore b/.gitignore index 691f30d8..41188af7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ node_modules # Rust build folder backend/target -frontend/obsidian-plugin/dist -frontend/sync-client/dist +frontend/*/dist backend/db.sqlite3* backend/config.yml diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts new file mode 100644 index 00000000..c597430a --- /dev/null +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -0,0 +1,107 @@ +import { choose } from "../utils/choose"; +import { v4 as uuidv4 } from "uuid"; +import { assert } from "../utils/assert"; +import { SyncSettings } from "sync-client"; +import { MockClient } from "./mock-client"; + +export class MockAgent extends MockClient { + private writtenContents: Array = []; + private pendingActions: Array> = []; + + public constructor( + globalFiles: Record, + initialSettings: Partial, + private readonly name: string + ) { + super(globalFiles, initialSettings); + } + + public async act(): Promise { + let options: Array<() => Promise> = [ + () => + this.create( + this.getFileName(), + new TextEncoder().encode(this.getContent()) + ), + () => + this.client.settings.setSetting( + "fetchChangesUpdateIntervalMs", + Math.random() * 1000 + ), + () => this.client.settings.setSetting("isSyncEnabled", false), + () => this.client.settings.setSetting("isSyncEnabled", true) + ]; + + let files = await this.listAllFiles(); + + if (files.length > 0) { + options.push( + () => this.rename(choose(files), this.getFileName()), + () => + this.atomicUpdateText( + choose(files), + (old) => old + " " + this.getContent() + ) + ); + } + + this.pendingActions.push(choose(options)()); + } + + private getContent() { + const uuid = uuidv4(); + this.writtenContents.push(uuid); + return uuid; + } + + private getFileName() { + return `${this.name}-${uuidv4()}.md`; + } + + public async finish(): Promise { + await Promise.all(this.pendingActions); + await this.client.settings.setSetting("isSyncEnabled", true); + await this.client.syncer.applyRemoteChangesLocally(); + } + + public assertFileSystemIsConsistent(): void { + const files = Object.keys(this.globalFiles); + const localFiles = Object.keys(this.files); + + assert( + files.length === localFiles.length, + `File count mismatch: ${files.length} != ${localFiles.length}` + ); + + for (const file of files) { + assert( + file in this.globalFiles, + `File ${file} missing in global files` + ); + assert( + new TextDecoder().decode(this.globalFiles[file]) === + new TextDecoder().decode(this.files[file]), + `File ${file} content mismatch` + ); + } + } + + public assertAllContentIsPresentOnce(): void { + for (const content of this.writtenContents) { + const found = Object.values(this.files).filter((file) => { + return new TextDecoder().decode(file).includes(content); + }); + + assert( + found.length === 1, + `Content ${content} found in ${found.length} files` + ); + + const file = found[0]; + assert( + new TextDecoder().decode(file).split(content).length === 2, + `Content ${content} found more than once in a file` + ); + } + } +} diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts new file mode 100644 index 00000000..a967f54f --- /dev/null +++ b/frontend/test-client/src/agent/mock-client.ts @@ -0,0 +1,125 @@ +import { + SyncClient, + RelativePath, + FileSystemOperations, + SyncSettings +} from "sync-client"; +import { assert } from "../utils/assert"; + +export class MockClient implements FileSystemOperations { + protected readonly files: Record = {}; + protected client!: SyncClient; + + public constructor( + protected readonly globalFiles: Record, + private readonly initialSettings: Partial + ) {} + + public async init() { + let _data: unknown = ""; + + this.client = await SyncClient.create(this, { + load: async () => _data, + save: async (data: unknown) => void (_data = data) + }); + + Object.keys(this.initialSettings).forEach((key) => { + this.client.settings.setSetting( + key as keyof SyncSettings, + this.initialSettings[key as keyof SyncSettings] + ); + }); + + assert( + (await this.client.checkConnection()).isSuccessful, + "Connection check failed" + ); + } + + public async listAllFiles(): Promise { + return Object.keys(this.files) as RelativePath[]; + } + + public async read(path: RelativePath): Promise { + return this.files[path]; + } + + public async getFileSize(path: RelativePath): Promise { + return this.files[path].length; + } + + public async getModificationTime(path: RelativePath): Promise { + return new Date(); + } + + public async exists(path: RelativePath): Promise { + return path in this.files; + } + + public async create( + path: RelativePath, + newContent: Uint8Array + ): Promise { + this.globalFiles[path] = newContent; + this.files[path] = newContent; + this.client.syncer.syncLocallyCreatedFile(path, new Date()); + } + + public async createDirectory(path: RelativePath): Promise {} + + public async atomicUpdateText( + path: RelativePath, + updater: (currentContent: string) => string + ): Promise { + const currentContent = new TextDecoder().decode(this.files[path]); + const newContent = updater(currentContent); + const newContentUint8Array = new TextEncoder().encode(newContent); + this.globalFiles[path] = newContentUint8Array; + this.files[path] = newContentUint8Array; + this.client.syncer.syncLocallyUpdatedFile({ + relativePath: path, + updateTime: new Date() + }); + return newContent; + } + + public async write(path: RelativePath, content: Uint8Array): Promise { + this.globalFiles[path] = content; + this.files[path] = content; + this.client.syncer.syncLocallyUpdatedFile({ + relativePath: path, + updateTime: new Date() + }); + } + + public async delete(path: RelativePath): Promise { + delete this.files[path]; + if (path in this.globalFiles) { + delete this.globalFiles[path]; + } + this.client.syncer.syncLocallyDeletedFile(path); + } + + public async rename( + oldPath: RelativePath, + newPath: RelativePath + ): Promise { + this.files[newPath] = this.files[oldPath]; + delete this.files[oldPath]; + + if (oldPath in this.globalFiles) { + this.globalFiles[newPath] = this.files[oldPath]; + delete this.globalFiles[oldPath]; + } + + this.client.syncer.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath, + updateTime: new Date() + }); + } + + public isFileEligibleForSync(path: RelativePath): boolean { + return true; + } +} diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts new file mode 100644 index 00000000..80ea3f33 --- /dev/null +++ b/frontend/test-client/src/cli.ts @@ -0,0 +1,43 @@ +import { SyncSettings } from "sync-client"; +import { MockAgent } from "./agent/mock-agent"; +import { sleep } from "./utils/sleep"; +import { v4 as uuidv4 } from "uuid"; + +const globalFiles: Record = {}; +const iterations = 100; + +async function runTest(): Promise { + console.info("Starting test..."); + + const initialSettings: Partial = { + isSyncEnabled: true, + token: "token", + vaultName: uuidv4() + }; + + const clients = [ + new MockAgent(globalFiles, initialSettings, "agent-1"), + new MockAgent(globalFiles, initialSettings, "agent-2"), + new MockAgent(globalFiles, initialSettings, "agent-3"), + new MockAgent(globalFiles, initialSettings, "agent-4"), + new MockAgent(globalFiles, initialSettings, "agent-5") + ]; + + await Promise.all(clients.map((client) => client.init())); + + for (let i = 0; i < iterations; i++) { + await Promise.all(clients.map((client) => client.act())); + await sleep(100); + } + + await Promise.all(clients.map((client) => client.finish())); + + clients.forEach((client) => { + client.assertFileSystemIsConsistent(); + client.assertAllContentIsPresentOnce(); + }); + + console.info("Test completed successfully"); +} + +runTest(); diff --git a/frontend/test-client/src/utils/assert.ts b/frontend/test-client/src/utils/assert.ts new file mode 100644 index 00000000..e1e3bb98 --- /dev/null +++ b/frontend/test-client/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/test-client/src/utils/choose.ts b/frontend/test-client/src/utils/choose.ts new file mode 100644 index 00000000..adb1dc7c --- /dev/null +++ b/frontend/test-client/src/utils/choose.ts @@ -0,0 +1,3 @@ +export function choose(values: T[]): T { + return values[Math.floor(Math.random() * values.length)]; +} diff --git a/frontend/test-client/src/utils/sleep.ts b/frontend/test-client/src/utils/sleep.ts new file mode 100644 index 00000000..8b8bcd5e --- /dev/null +++ b/frontend/test-client/src/utils/sleep.ts @@ -0,0 +1,3 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}