From 68c96ac9069d1f672d874d275b8ad253e6757299 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 30 Oct 2026 17:15:35 -0400 Subject: [PATCH 1/2] feat: Make the Vue and React Native contract test WebSocket extend the shared base --- .../entity/src/TestHarnessWebSocket.ts | 124 ++++------------ .../src/TestHarnessWebSocket.ts | 139 ++++++------------ .../src/client-side/TestHarnessWebSocket.ts | 4 + .../tooling/contract-test-utils/src/client.ts | 5 +- 4 files changed, 86 insertions(+), 186 deletions(-) diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts index dc17adc163..cb4f2dad26 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,51 +1,12 @@ -import { makeLogger } from '@launchdarkly/js-contract-test-utils/client'; -import { LDLogger } from '@launchdarkly/react-native-client-sdk'; +import { + CommandParams, + CreateInstanceParams, + TestHarnessWebSocket as BaseTestHarnessWebSocket, +} from '@launchdarkly/js-contract-test-utils/client'; import { ClientEntity, newSdkClientEntity } from './ClientEntity'; -export default class TestHarnessWebSocket { - private _ws?: WebSocket; - private readonly _entities: Record = {}; - private _clientCounter = 0; - private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); - private _intentionalClose = false; - private _onConnectionChange?: (connected: boolean) => void; - - constructor( - private readonly _url: string, - onConnectionChange?: (connected: boolean) => void, - ) { - this._onConnectionChange = onConnectionChange; - } - - connect() { - this._intentionalClose = false; - this._logger.info(`Connecting to web socket.`); - this._ws = new WebSocket(this._url, 'v1'); - this._ws.onopen = () => { - this._logger.info('Connected to websocket.'); - this._onConnectionChange?.(true); - }; - this._ws.onclose = () => { - this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); - this._onConnectionChange?.(false); - if (!this._intentionalClose) { - setTimeout(() => { - this.connect(); - }, 1000); - } - }; - this._ws.onerror = (err) => { - this._logger.info(`error:`, err); - }; - - this._ws.onmessage = async (msg) => { - this._logger.info('Test harness message', msg); - const data = JSON.parse(msg.data as string); - const resData: any = { reqId: data.reqId }; - switch (data.command) { - case 'getCapabilities': - resData.capabilities = [ +const CAPABILITIES = [ 'client-side', 'mobile', 'service-endpoints', @@ -65,60 +26,35 @@ export default class TestHarnessWebSocket { 'bootstrap', ]; - break; - case 'createClient': - try { - resData.resourceUrl = `/clients/${this._clientCounter}`; - resData.status = 201; - const entity = await newSdkClientEntity(data.body); - this._entities[this._clientCounter] = entity; - this._clientCounter += 1; - } catch (e: any) { - this._logger.error(`Failed to create client: ${e?.message ?? e}`); - resData.status = 500; - } - break; - case 'runCommand': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - try { - const body = await entity.doCommand(data.body); - resData.body = body; - resData.status = body ? 200 : 204; - } catch (e: any) { - this._logger.error(`Command failed: ${e?.message ?? e}`); - resData.status = 500; - } - } else { - resData.status = 404; - this._logger.warn(`Client did not exist: ${data.id}`); - } +export default class TestHarnessWebSocket extends BaseTestHarnessWebSocket { + constructor(url: string, onConnectionChange?: (connected: boolean) => void) { + super(url, { capabilities: CAPABILITIES, onConnectionChange }); + } - break; - case 'deleteClient': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - entity.close(); - delete this._entities[data.id]; - } else { - resData.status = 404; - this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); - } - break; - default: - break; - } + private readonly _entities: Record = {}; + private _clientCounter = 0; - this.send(resData); - }; + protected async createClient(params: CreateInstanceParams): Promise { + const entity = await newSdkClientEntity(params); + const id = String(this._clientCounter); + this._clientCounter += 1; + this._entities[id] = entity; + return id; } - disconnect() { - this._intentionalClose = true; - this._ws?.close(); + protected getCommandHandler(id: string) { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return undefined; + } + return (params: CommandParams) => this._entities[id].doCommand(params); } - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); + protected deleteClient(id: string): boolean { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return false; + } + this._entities[id].close(); + delete this._entities[id]; + return true; } } diff --git a/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts index 95de66e09e..2344591b28 100644 --- a/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts +++ b/packages/sdk/vue/contract-tests/src/TestHarnessWebSocket.ts @@ -1,103 +1,60 @@ -import { makeLogger } from '@launchdarkly/js-contract-test-utils/client'; -import type { LDLogger } from '@launchdarkly/vue-client-sdk'; +import { + CommandParams, + CreateInstanceParams, + TestHarnessWebSocket as BaseTestHarnessWebSocket, +} from '@launchdarkly/js-contract-test-utils/client'; import { ClientEntity, newSdkClientEntity } from './ClientEntity'; -export default class TestHarnessWebSocket { - private _ws?: WebSocket; +const CAPABILITIES = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'client-prereq-cycle-detection', + 'evaluation-hooks', + 'track-hooks', + 'secure-mode-hash', + 'flag-change-listeners', + 'fdv1-fallback', + 'bootstrap', +]; + +export default class TestHarnessWebSocket extends BaseTestHarnessWebSocket { + constructor(url: string) { + super(url, { capabilities: CAPABILITIES }); + } + private readonly _entities: Record = {}; private _clientCounter = 0; - private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); - - constructor(private readonly _url: string) {} - - connect() { - this._logger.info(`Connecting to web socket.`); - this._ws = new WebSocket(this._url, ['v1']); - this._ws.onopen = () => { - this._logger.info('Connected to websocket.'); - }; - this._ws.onclose = () => { - this._logger.info('Websocket closed. Attempting to reconnect in 1 second.'); - setTimeout(() => { - this.connect(); - }, 1000); - }; - this._ws.onerror = (err) => { - this._logger.info(`error:`, err); - }; - - this._ws.onmessage = async (msg) => { - this._logger.info('Test harness message', msg); - const data = JSON.parse(msg.data); - const resData: any = { reqId: data.reqId }; - switch (data.command) { - case 'getCapabilities': - resData.capabilities = [ - 'client-side', - 'service-endpoints', - 'tags', - 'user-type', - 'inline-context', - 'inline-context-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-per-context-summaries', - 'client-prereq-cycle-detection', - 'evaluation-hooks', - 'track-hooks', - 'secure-mode-hash', - 'flag-change-listeners', - 'fdv1-fallback', - 'bootstrap', - ]; - - break; - case 'createClient': - { - resData.resourceUrl = `/clients/${this._clientCounter}`; - resData.status = 201; - const entity = await newSdkClientEntity(data.body); - this._entities[this._clientCounter] = entity; - this._clientCounter += 1; - } - break; - case 'runCommand': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - const body = await entity.doCommand(data.body); - resData.body = body; - resData.status = body ? 200 : 204; - } else { - resData.status = 404; - this._logger.warn(`Client did not exist: ${data.id}`); - } - - break; - case 'deleteClient': - if (Object.prototype.hasOwnProperty.call(this._entities, data.id)) { - const entity = this._entities[data.id]; - entity.close(); - delete this._entities[data.id]; - } else { - resData.status = 404; - this._logger.warn(`Could not delete client because it did not exist: ${data.id}`); - } - break; - default: - break; - } - this.send(resData); - }; + protected async createClient(params: CreateInstanceParams): Promise { + const entity = await newSdkClientEntity(params); + const id = String(this._clientCounter); + this._clientCounter += 1; + this._entities[id] = entity; + return id; } - disconnect() { - this._ws?.close(); + protected getCommandHandler(id: string) { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return undefined; + } + return (params: CommandParams) => this._entities[id].doCommand(params); } - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); + protected deleteClient(id: string): boolean { + if (!Object.prototype.hasOwnProperty.call(this._entities, id)) { + return false; + } + this._entities[id].close(); + delete this._entities[id]; + return true; } } diff --git a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts index a32e1533cc..19f0d495cb 100644 --- a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -24,6 +24,8 @@ const DEFAULT_CAPABILITIES = [ export interface TestHarnessWebSocketOptions { /** Capabilities reported to the harness. Defaults to the browser list. */ capabilities?: string[]; + /** Called with true when the socket opens and false when it closes. */ + onConnectionChange?: (connected: boolean) => void; } /** @@ -63,8 +65,10 @@ export default abstract class TestHarnessWebSocket { this._ws = new WebSocket(this._url, ['v1']); this._ws.onopen = () => { this.logger.info('Connected to websocket.'); + this._options.onConnectionChange?.(true); }; this._ws.onclose = () => { + this._options.onConnectionChange?.(false); if (this._intentionalClose) { this.logger.info('Websocket closed.'); return; diff --git a/packages/tooling/contract-test-utils/src/client.ts b/packages/tooling/contract-test-utils/src/client.ts index 642e1f02cc..c4a78b4123 100644 --- a/packages/tooling/contract-test-utils/src/client.ts +++ b/packages/tooling/contract-test-utils/src/client.ts @@ -4,4 +4,7 @@ export * from './index.js'; // Client-side exports export { default as ClientSideTestHook } from './client-side/TestHook.js'; export { default as TestHarnessWebSocket } from './client-side/TestHarnessWebSocket.js'; -export type { CommandHandler } from './client-side/TestHarnessWebSocket.js'; +export type { + CommandHandler, + TestHarnessWebSocketOptions, +} from './client-side/TestHarnessWebSocket.js'; From 8f673e33268888d8ac319989ffadac6cf1352dd5 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 30 Oct 2026 18:25:35 -0400 Subject: [PATCH 2/2] style: fix lint issue --- .../entity/src/TestHarnessWebSocket.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts index cb4f2dad26..a11fc8c043 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -7,24 +7,24 @@ import { import { ClientEntity, newSdkClientEntity } from './ClientEntity'; const CAPABILITIES = [ - 'client-side', - 'mobile', - 'service-endpoints', - 'tags', - 'user-type', - 'inline-context-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-prereq-cycle-detection', - 'client-per-context-summaries', - 'track-hooks', - 'fdv1-fallback', - 'client-event-source-http-errors', - 'retry-conformance-fdv1-streaming', - 'retry-conformance-fdv1-polling', - 'bootstrap', - ]; + 'client-side', + 'mobile', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-prereq-cycle-detection', + 'client-per-context-summaries', + 'track-hooks', + 'fdv1-fallback', + 'client-event-source-http-errors', + 'retry-conformance-fdv1-streaming', + 'retry-conformance-fdv1-polling', + 'bootstrap', +]; export default class TestHarnessWebSocket extends BaseTestHarnessWebSocket { constructor(url: string, onConnectionChange?: (connected: boolean) => void) {