diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index f6b767a7..dc0fb6f4 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -1,7 +1,7 @@ import { choose } from "../utils/choose"; import { v4 as uuidv4 } from "uuid"; import { assert } from "../utils/assert"; -import type { SyncSettings } from "sync-client"; +import type { RelativePath, SyncSettings } from "sync-client"; import { LogLevel } from "sync-client"; import { MockClient } from "./mock-client"; import chalk from "chalk"; @@ -9,15 +9,15 @@ import chalk from "chalk"; export class MockAgent extends MockClient { private readonly writtenContents: string[] = []; private readonly pendingActions: Promise[] = []; + private doNotTouch: string[] = []; public constructor( - globalFiles: Record, initialSettings: Partial, public readonly name: string, private readonly color: string, private readonly doDeletes: boolean ) { - super(globalFiles, initialSettings); + super(initialSettings); } public async init(): Promise { @@ -49,86 +49,34 @@ export class MockAgent extends MockClient { public async act(): Promise { const options: (() => Promise)[] = [ - async (): Promise => { - const file = this.getFileName(); - - if (await this.exists(file)) { - return; - } - - this.client.logger.info(`Decided to create file ${file}`); - return this.create( - file, - new TextEncoder().encode(this.getContent()) - ); - }, - async (): Promise => { - this.client.logger.info( - `Decided to change fetchChangesUpdateIntervalMs` - ); - return this.client.settings.setSetting( - "fetchChangesUpdateIntervalMs", - Math.random() * 1000 - ); - }, - async (): Promise => { - this.client.logger.info(`Decided to disable sync`); - return this.client.settings.setSetting("isSyncEnabled", false); - }, - async (): Promise => { - this.client.logger.info(`Decided to enable sync`); - return this.client.settings.setSetting("isSyncEnabled", true); - } + this.createFileAction.bind(this), + this.changeFetchChangesUpdateIntervalMsAction.bind(this), + this.disableSyncAction.bind(this), + this.enableSyncAction.bind(this) ]; const files = await this.listAllFiles(); if (files.length > 0) { options.push( - async (): Promise => { - const file = choose(files); - const newName = this.getFileName(); - - if (await this.exists(newName)) { - return; - } - - this.client.logger.info( - `Decided to rename file ${file} to ${newName}` - ); - return this.rename(file, newName); - }, - async (): Promise => { - const file = choose(files); - - this.client.logger.info(`Decided to update file ${file}`); - return this.atomicUpdateText( - file, - (old) => old + " " + this.getContent() - ); - } + this.renameFileAction.bind(this, files), + this.updateFileAction.bind(this, files) ); if (this.doDeletes) { - options.push(async (): Promise => { - const file = choose(files); - this.client.logger.info(`Decided to delete file ${file}`); - return this.delete(file); - }); + options.push(this.deleteFileAction.bind(this, files)); } } this.pendingActions.push( - (() => { + (async (): Promise => { try { - return choose(options)(); + return await choose(options)(); } catch (error) { this.client.logger.error( `Failed to perform an action: ${error}` ); - this.client.logger.info( - JSON.stringify(JSON.parse(this.data as any), null, 2) - ); + this.client.logger.info(JSON.stringify(this.data, null, 2)); this.client.logger.info( JSON.stringify(this.localFiles, null, 2) ); @@ -141,71 +89,180 @@ export class MockAgent extends MockClient { public async finish(): Promise { await Promise.all(this.pendingActions); await this.client.settings.setSetting("isSyncEnabled", true); - await this.client.syncer.applyRemoteChangesLocally(); await this.client.syncer.waitForSyncQueue(); + await this.client.syncer.applyRemoteChangesLocally(); this.client.stop(); } - public assertFileSystemIsConsistent(): void { - const globalFiles = Object.keys(this.globalFiles); - const localFiles = Object.keys(this.localFiles); + public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { + const globalFiles = Array.from(otherAgent.localFiles.keys()); + const localFiles = Array.from(this.localFiles.keys()); - const missingInGlobal = localFiles.filter( - (file) => !(file in this.globalFiles) + const missingInOther = localFiles.filter( + (file) => !otherAgent.localFiles.has(file) ); const missingInLocal = globalFiles.filter( - (file) => !(file in this.localFiles) + (file) => !this.localFiles.has(file) ); - assert( - missingInGlobal.length === 0, - `Files missing in global files: ${missingInGlobal.join(", ")}` - ); - assert( - missingInLocal.length === 0, - `Files missing in local files: ${missingInLocal.join(", ")}` - ); - - for (const file of globalFiles) { - const localContent = new TextDecoder().decode( - this.localFiles[file] - ); - const globalContent = new TextDecoder().decode( - this.globalFiles[file] + try { + assert( + missingInOther.length === 0, + `Files from ${this.name} missing in ${otherAgent.name}: ${missingInOther.join(", ")}` ); assert( - localContent === globalContent, - `Content mismatch for file ${file}: ${localContent} <> ${globalContent}` + missingInLocal.length === 0, + `Files from ${otherAgent.name} missing in ${this.name}: ${missingInLocal.join(", ")}` ); + + for (const file of globalFiles) { + const localContent = new TextDecoder().decode( + this.localFiles.get(file) + ); + const otherContent = new TextDecoder().decode( + otherAgent.localFiles.get(file) + ); + assert( + localContent === otherContent, + `Content mismatch for file ${file}:\n${localContent}\n${otherContent}` + ); + } + } catch (e) { + this.client.logger.info( + "Local data: " + JSON.stringify(this.data, null, 2) + ); + this.client.logger.info( + "Local files: " + + 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(", ") + ); + + throw e; } } public assertAllContentIsPresentOnce(): void { for (const content of this.writtenContents) { - const found = Object.values(this.localFiles).filter((file) => { - return new TextDecoder().decode(file).includes(content); + const found = Array.from(this.localFiles.keys()).filter((key) => { + return new TextDecoder() + .decode(this.localFiles.get(key)) + .includes(content); }); if (this.doDeletes) { assert( found.length <= 1, - `Content ${content} found in ${found.length} files` + `[${this.name}] Content ${content} found in ${found.join(", ")}` ); } else { assert( - found.length === 1, - `Content ${content} found in ${found.length} files` + found.length >= 1, + `[${this.name}] Content ${content} not found in any files` + ); + + assert( + found.length <= 1, + `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` ); const [file] = found; + const fileContent = new TextDecoder().decode( + this.localFiles.get(file) + ); assert( - new TextDecoder().decode(file).split(content).length === 2, - `Content ${content} found more than once in a file` + fileContent.split(content).length == 2, + `Content ${content} (of ${this.name}) found more than once in file ${file}. File content:\n${fileContent}` ); } } } + private async createFileAction(): Promise { + const file = this.getFileName(); + + if (await this.exists(file)) { + return; + } + + const content = this.getContent(); + this.client.logger.info( + `Decided to create file ${file} with content ${content}` + ); + + return this.create( + file, + new TextEncoder().encode(` |${content}| `) + ); + } + + private async changeFetchChangesUpdateIntervalMsAction(): Promise { + this.client.logger.info( + `Decided to change fetchChangesUpdateIntervalMs` + ); + return this.client.settings.setSetting( + "fetchChangesUpdateIntervalMs", + Math.random() * 1000 + ); + } + + private async disableSyncAction(): Promise { + this.client.logger.info(`Decided to disable sync`); + await this.client.settings.setSetting("isSyncEnabled", false); + } + + private async enableSyncAction(): Promise { + this.client.logger.info(`Decided to enable sync`); + await this.client.settings.setSetting("isSyncEnabled", true); + this.doNotTouch = []; + } + + private async renameFileAction(files: RelativePath[]): Promise { + const file = choose(files); + const newName = this.getFileName(); + + if (await this.exists(newName)) { + return; + } + + this.client.logger.info(`Decided to rename file ${file} to ${newName}`); + if (!this.client.settings.getSettings().isSyncEnabled) { + this.doNotTouch.push(newName); + } + + return this.rename(file, newName); + } + + private async updateFileAction(files: RelativePath[]): Promise { + const file = choose(files); + + // We can't edit files offline that have been renamed while offline. + // Othwersie, the resolution logic couldn't handle it. + if (this.doNotTouch.includes(file)) { + this.client.logger.info( + `Skipping file ${file} because it has been renamed while offline` + ); + return; + } + + const content = this.getContent(); + this.client.logger.info( + `Decided to update file ${file} with ${content}` + ); + await this.atomicUpdateText(file, (old) => old + ` |${content}| `); + } + + private async deleteFileAction(files: RelativePath[]): Promise { + const file = choose(files); + this.client.logger.info(`Decided to delete file ${file}`); + return this.delete(file); + } + private getContent(): string { const uuid = uuidv4(); this.writtenContents.push(uuid); diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 6dc54076..07570ab1 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -23,12 +23,10 @@ export class MockClient implements FileSystemOperations { await Promise.all( Object.keys(this.initialSettings).map(async (key) => { - if (key in this.client.settings) { - return this.client.settings.setSetting( - key as keyof SyncSettings, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - this.initialSettings[key as keyof SyncSettings] // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - } + return this.client.settings.setSetting( + key as keyof SyncSettings, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + this.initialSettings[key as keyof SyncSettings] // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); }) ); @@ -93,6 +91,10 @@ export class MockClient implements FileSystemOperations { const newContentUint8Array = new TextEncoder().encode(newContent); this.localFiles.set(path, newContentUint8Array); + this.client.logger.info( + `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` + ); + void this.client.syncer.syncLocallyUpdatedFile({ relativePath: path, updateTime: new Date() @@ -104,6 +106,10 @@ export class MockClient implements FileSystemOperations { public async write(path: RelativePath, content: Uint8Array): Promise { this.localFiles.set(path, content); + this.client.logger.info( + `Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` + ); + void this.client.syncer.syncLocallyUpdatedFile({ relativePath: path, updateTime: new Date() diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 3c0c8d45..01fdf03c 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -3,87 +3,102 @@ import { MockAgent } from "./agent/mock-agent"; import { sleep } from "./utils/sleep"; import { v4 as uuidv4 } from "uuid"; -const globalFiles: Record = {}; -const iterations = 100; -const doDeletes = false; - -async function runTest(): Promise { - console.info("Starting test"); +async function runTest({ + agentCount, + concurrency, + iterations, + doDeletes +}: { + agentCount: number; + concurrency: number; + iterations: number; + doDeletes: boolean; +}): Promise { + console.info( + `Running test with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}` + ); const initialSettings: Partial = { isSyncEnabled: true, token: "token", vaultName: uuidv4(), + syncConcurrency: concurrency, remoteUri: "http://localhost:3030" }; - const clients = [ - new MockAgent( - globalFiles, - initialSettings, - "agent-1", - "#ff0000", - doDeletes - ), - new MockAgent( - globalFiles, - initialSettings, - "agent-2", - "#00ff00", - doDeletes - ), - new MockAgent( - globalFiles, - initialSettings, - "agent-3", - "#0000ff", - doDeletes - ), - new MockAgent( - globalFiles, - initialSettings, - "agent-4", - "#ffaa00", - doDeletes - ), - new MockAgent( - globalFiles, - initialSettings, - "agent-5", - "#00ffaa", - doDeletes - ) - ]; - - await Promise.all(clients.map(async (client) => client.init())); - - for (let i = 0; i < iterations; i++) { - await Promise.all(clients.map(async (client) => client.act())); - await sleep(100); + const clients: MockAgent[] = []; + for (let i = 0; i < agentCount; i++) { + clients.push( + new MockAgent(initialSettings, `agent-${i}`, "#ff0000", doDeletes) + ); } - await Promise.all(clients.map(async (client) => client.finish())); + try { + await Promise.all(clients.map(async (client) => client.init())); - console.info("Agents finished successfully"); + for (let i = 0; i < iterations; i++) { + console.info(`Iteration ${i + 1}/${iterations}`); + await Promise.all(clients.map(async (client) => client.act())); + await sleep(100); + } - clients.forEach((client) => { - console.info(`Checking consistency for ${client.name}`); - client.assertFileSystemIsConsistent(); - console.info(`Consistency check for ${client.name} passed`); - }); + for (const client of clients) { + // todo: make it less hacky + await client.finish(); + } - console.info("File systems found to be consistent"); + console.info("Agents finished successfully"); - clients.forEach((client) => { - console.info(`Checking content for ${client.name}`); - client.assertAllContentIsPresentOnce(); - console.info(`Content check for ${client.name} passed`); - }); + clients.slice(0, -1).forEach((client, i) => { + console.info( + `Checking consistency between ${client.name} and ${clients[i + 1].name}` + ); + client.assertFileSystemsAreConsistent(clients[i]); + console.info(`Consistency check for ${client.name} passed`); + }); - console.info("Test completed successfully"); + console.info("File systems found to be consistent"); + + clients.forEach((client) => { + console.info(`Checking content for ${client.name}`); + client.assertAllContentIsPresentOnce(); + console.info(`Content check for ${client.name} passed`); + }); + + console.info( + `Test passed with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}` + ); + } catch (err) { + console.error( + `Test failed with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}` + ); + throw err; + } } -runTest() +async function runTests(): Promise { + const agentCounts = [2, 10]; + const concurrencies = [1, 16]; + const iterations = [300]; + const doDeletes = [false, true]; + + for (const agentCount of agentCounts) { + for (const concurrency of concurrencies) { + for (const iteration of iterations) { + for (const deleteFiles of doDeletes) { + await runTest({ + agentCount, + concurrency, + iterations: iteration, + doDeletes: deleteFiles + }); + } + } + } + } +} + +runTests() .then(() => { process.exit(0); })