From 3ec6bd4d5b45cc134d476cdf8041d61b3d0a5584 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Mon, 7 Apr 2025 23:13:45 +0100 Subject: [PATCH] Allow overriding WebSocket implementation and add flaky version for testing --- frontend/sync-client/package.json | 68 +++++++++---------- .../sync-client/src/services/sync-service.ts | 15 ++-- frontend/sync-client/src/sync-client.ts | 12 ++-- .../sync-client/src/sync-operations/syncer.ts | 39 +++++++---- frontend/test-client/src/agent/mock-agent.ts | 14 ++-- frontend/test-client/src/agent/mock-client.ts | 6 +- frontend/test-client/src/utils/flaky-fetch.ts | 20 ++++++ .../test-client/src/utils/flaky-websocket.ts | 61 +++++++++++++++++ 8 files changed, 162 insertions(+), 73 deletions(-) create mode 100644 frontend/test-client/src/utils/flaky-fetch.ts create mode 100644 frontend/test-client/src/utils/flaky-websocket.ts diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 770decf..51b174f 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -1,36 +1,36 @@ { - "name": "sync-client", - "version": "0.3.8", - "main": "dist/sync-client.node.js", - "browser": "dist/sync-client.web.js", - "types": "dist/types/index.d.ts", - "files": [ - "dist/**/*" - ], - "scripts": { - "dev": "webpack watch --mode development", - "build": "webpack --mode production", - "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" - }, - "dependencies": { - "byte-base64": "^1.1.0", - "openapi-fetch": "0.13.5", - "openapi-typescript": "7.6.1", - "p-queue": "^8.1.0", - "uuid": "^11.1.0" - }, - "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^22.14.0", - "jest": "^29.7.0", - "sync_lib": "file:../../backend/sync_lib/pkg", - "ts-jest": "^29.3.1", - "ts-loader": "^9.5.2", - "tslib": "2.8.1", - "typescript": "5.8.2", - "webpack": "^5.98.0", - "webpack-cli": "^6.0.1", - "webpack-merge": "^6.0.1", - "ws": "^8.18.1" - } + "name": "sync-client", + "version": "0.3.8", + "main": "dist/sync-client.node.js", + "browser": "dist/sync-client.web.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist/**/*" + ], + "scripts": { + "dev": "webpack watch --mode development", + "build": "webpack --mode production", + "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest" + }, + "dependencies": { + "byte-base64": "^1.1.0", + "openapi-fetch": "0.13.5", + "openapi-typescript": "7.6.1", + "p-queue": "^8.1.0", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^22.14.0", + "jest": "^29.7.0", + "sync_lib": "file:../../backend/sync_lib/pkg", + "ts-jest": "^29.3.1", + "ts-loader": "^9.5.2", + "tslib": "2.8.1", + "typescript": "5.8.2", + "webpack": "^5.98.0", + "webpack-cli": "^6.0.1", + "webpack-merge": "^6.0.1", + "ws": "^8.18.1" + } } diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 7251ef7..69eae6c 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -21,13 +21,13 @@ export class SyncService { private static readonly NETWORK_RETRY_INTERVAL_MS = 1000; private client: Client; private pingClient: Client; - private _fetchImplementation: typeof globalThis.fetch = globalThis.fetch; public constructor( private readonly deviceId: string, private readonly connectionStatus: ConnectionStatus, private readonly settings: Settings, - private readonly logger: Logger + private readonly logger: Logger, + private readonly fetchImplementation: typeof globalThis.fetch = globalThis.fetch ) { [this.client, this.pingClient] = this.createClient( this.settings.getSettings().remoteUri @@ -44,13 +44,6 @@ export class SyncService { }); } - public set fetchImplementation(fetch: typeof globalThis.fetch) { - this._fetchImplementation = fetch; - [this.client, this.pingClient] = this.createClient( - this.settings.getSettings().remoteUri - ); - } - private static formatError( error: components["schemas"]["SerializedError"] ): string { @@ -329,7 +322,7 @@ export class SyncService { baseUrl: remoteUri, fetch: this.connectionStatus.getFetchImplementation( this.logger, - this._fetchImplementation + this.fetchImplementation ), headers: { authorization: `Bearer ${this.settings.getSettings().token}` @@ -337,7 +330,7 @@ export class SyncService { }), createClient({ baseUrl: remoteUri, - fetch: this._fetchImplementation, + fetch: this.fetchImplementation, headers: { authorization: `Bearer ${this.settings.getSettings().token}` } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 228b29e..ab1cd76 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -56,7 +56,8 @@ export class SyncClient { public static async create({ fs, persistence, - fetch = globalThis.fetch, + fetch, + webSocket, nativeLineEndings = "\n" }: { fs: FileSystemOperations; @@ -67,6 +68,7 @@ export class SyncClient { }> >; fetch?: typeof globalThis.fetch; + webSocket?: typeof globalThis.WebSocket; nativeLineEndings?: string; }): Promise { const logger = new Logger(); @@ -113,9 +115,10 @@ export class SyncClient { deviceId, connectionStatus, settings, - logger + logger, + fetch ); - syncService.fetchImplementation = fetch; + const fileOperations = new FileOperations( logger, database, @@ -137,7 +140,8 @@ export class SyncClient { settings, syncService, fileOperations, - unrestrictedSyncer + unrestrictedSyncer, + webSocket ); const client = new SyncClient( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index ff10266..7d7982d 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -31,6 +31,8 @@ export class Syncer { | undefined; private applyRemoteChangesWebSocket: WebSocket | undefined; + private readonly webSocketImplementation: typeof globalThis.WebSocket; + // eslint-disable-next-line @typescript-eslint/max-params public constructor( private readonly deviceId: string, @@ -39,12 +41,27 @@ export class Syncer { private readonly settings: Settings, private readonly syncService: SyncService, private readonly operations: FileOperations, - private readonly internalSyncer: UnrestrictedSyncer + private readonly internalSyncer: UnrestrictedSyncer, + webSocketImplementation?: typeof globalThis.WebSocket ) { this.syncQueue = new PQueue({ concurrency: settings.getSettings().syncConcurrency }); + if (webSocketImplementation) { + this.webSocketImplementation = webSocketImplementation; + } else { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.WebSocket === "undefined" + ) { + // eslint-disable-next-line + this.webSocketImplementation = require("ws"); // polyfill for WebSocket in Node.js + } else { + this.webSocketImplementation = WebSocket; + } + } + this.updateWebSocket(settings.getSettings()); this.remoteDocumentsLock = new Locks(this.logger); @@ -74,7 +91,10 @@ export class Syncer { } public get isWebSocketConnected(): boolean { - return this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN; + return ( + this.applyRemoteChangesWebSocket?.readyState === + this.webSocketImplementation.OPEN + ); } public addRemainingOperationsListener( @@ -270,15 +290,9 @@ export class Syncer { this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); - if ( - typeof globalThis !== "undefined" && - typeof globalThis.WebSocket === "undefined" - ) { - // eslint-disable-next-line - globalThis.WebSocket = require("ws"); // polyfill for WebSocket in Node.js - } - - this.applyRemoteChangesWebSocket = new WebSocket(wsUri); + this.applyRemoteChangesWebSocket = new this.webSocketImplementation( + wsUri + ); this.applyRemoteChangesWebSocket.onmessage = (event): void => void this.syncRemotelyUpdatedFile(event.data).catch( @@ -316,7 +330,8 @@ export class Syncer { private setWebSocketRefreshInterval(): void { this.refreshApplyRemoteChangesWebSocketInterval = setInterval(() => { if ( - this.applyRemoteChangesWebSocket?.readyState === WebSocket.OPEN + this.applyRemoteChangesWebSocket?.readyState === + this.webSocketImplementation.OPEN ) { return; } diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 9939d53..35dfe13 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -6,6 +6,8 @@ import { LogLevel } from "sync-client"; import { MockClient } from "./mock-client"; import { sleep } from "../utils/sleep"; import type { LogLine } from "sync-client/dist/types/tracing/logger"; +import { flakyFetchFactory } from "../utils/flaky-fetch"; +import { flakyWebSocketFactory } from "../utils/flaky-websocket"; export class MockAgent extends MockClient { private readonly writtenContents: string[] = []; @@ -26,16 +28,8 @@ export class MockAgent extends MockClient { public async init(): Promise { await super.init( - // flaky fetch implementation to use during testing - async ( - input: string | URL | globalThis.Request, - init?: RequestInit - ): Promise => { - await sleep(Math.random() * this.jitterScaleInSeconds * 1000); - const response = await fetch(input, init); - await sleep(Math.random() * this.jitterScaleInSeconds * 1000); - return response; - } + flakyFetchFactory(this.jitterScaleInSeconds), + flakyWebSocketFactory(this.jitterScaleInSeconds) ); assert( diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index a1e2b9e..766e798 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -30,7 +30,8 @@ export class MockClient implements FileSystemOperations { } public async init( - fetchImplementation: typeof globalThis.fetch + fetchImplementation: typeof globalThis.fetch, + webSocketImplementation: typeof globalThis.WebSocket ): Promise { this.client = await SyncClient.create({ fs: this, @@ -38,7 +39,8 @@ export class MockClient implements FileSystemOperations { load: async () => this.data, save: async (data) => void (this.data = data) }, - fetch: fetchImplementation + fetch: fetchImplementation, + webSocket: webSocketImplementation }); await this.client.start(); diff --git a/frontend/test-client/src/utils/flaky-fetch.ts b/frontend/test-client/src/utils/flaky-fetch.ts new file mode 100644 index 0000000..6a2c881 --- /dev/null +++ b/frontend/test-client/src/utils/flaky-fetch.ts @@ -0,0 +1,20 @@ +import { sleep } from "./sleep"; + +export const flakyFetchFactory = + (jitterScaleInSeconds: number) => + async ( + input: string | URL | globalThis.Request, + init?: RequestInit + ): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + const response = await fetch(input, init); + + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + return response; + }; diff --git a/frontend/test-client/src/utils/flaky-websocket.ts b/frontend/test-client/src/utils/flaky-websocket.ts new file mode 100644 index 0000000..f30c7f6 --- /dev/null +++ b/frontend/test-client/src/utils/flaky-websocket.ts @@ -0,0 +1,61 @@ +import { sleep } from "./sleep"; + +export function flakyWebSocketFactory( + jitterScaleInSeconds: number +): typeof WebSocket { + // eslint-disable-next-line + return class FlakyWebSocket extends require("ws") { + public set onopen(callback: (event: Event) => void) { + // eslint-disable-next-line + super.onopen = async (event: Event): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + callback(event); + }; + } + + public set onmessage(callback: (event: MessageEvent) => void) { + // eslint-disable-next-line + super.onmessage = async (event: MessageEvent): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + callback(event); + }; + } + + public set onclose(callback: (event: CloseEvent) => void) { + // eslint-disable-next-line + super.onclose = async (event: CloseEvent): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + callback(event); + }; + } + + public set onerror(callback: (event: Event) => void) { + // eslint-disable-next-line + super.onerror = async (event: Event): Promise => { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + callback(event); + }; + } + + public async send( + data: string | ArrayBufferLike | Blob | ArrayBufferView + ): Promise { + if (jitterScaleInSeconds > 0) { + await sleep(Math.random() * jitterScaleInSeconds * 1000); + } + + // eslint-disable-next-line + super.send(data); + } + } as unknown as typeof WebSocket; +}