diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 6e68ff061e..1698c1c132 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -1,28 +1,21 @@ import { createClient, InitializerEntry, - LDClient, - LDLogger, + LDContext, LDOptions, ModeDefinition, SynchronizerEntry, } from '@launchdarkly/js-client-sdk'; import { - CommandParams, - CommandType, + ClientEntity, + ConfigBuilder, CreateInstanceParams, - makeLogger, + IClientEntity, SDKConfigDataInitializer, SDKConfigDataSynchronizer, SDKConfigModeDefinition, - SDKConfigParams, - ClientSideTestHook as TestHook, - ValueType, } from '@launchdarkly/js-contract-test-utils/client'; -export const badCommandError = new Error('unsupported command'); -export const malformedCommand = new Error('command was malformed'); - function translateInitializer(init: SDKConfigDataInitializer): InitializerEntry | undefined { if (init.polling) { return { @@ -76,35 +69,27 @@ function translateModeDefinition(modeDef: SDKConfigModeDefinition): ModeDefiniti return { initializers, synchronizers }; } -function makeSdkConfig(options: SDKConfigParams, tag: string) { - if (!options.clientSide) { - throw new Error('configuration did not include clientSide options'); - } - - const isSet = (x?: unknown) => x !== null && x !== undefined; - const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds / 1000 : undefined); +export async function newSdkClientEntity( + _id: string, + options: CreateInstanceParams, +): Promise { + const config = new ConfigBuilder(options).set({ fetchGoals: false }); - const cf: LDOptions = { - withReasons: options.clientSide.evaluationReasons, - logger: makeLogger(`${tag}.sdk`), - useReport: options.clientSide.useReport ?? undefined, - }; + if (options.configuration.dataSystem) { + config.skip('streaming', 'polling'); - if (options.serviceEndpoints) { - cf.streamUri = options.serviceEndpoints.streaming; - cf.baseUri = options.serviceEndpoints.polling; - cf.eventsUri = options.serviceEndpoints.events; - } + const isSet = (x?: unknown) => x !== null && x !== undefined; + const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds! / 1000 : undefined); + const fdv2Overrides: Record = {}; - if (options.dataSystem?.payloadFilter) { - cf.payloadFilterKey = options.dataSystem.payloadFilter; - } + if (options.configuration.dataSystem.payloadFilter) { + fdv2Overrides.payloadFilterKey = options.configuration.dataSystem.payloadFilter; + } - if (options.dataSystem) { const dataSystem: any = {}; - if (options.dataSystem.connectionModeConfig) { - const connMode = options.dataSystem.connectionModeConfig; + if (options.configuration.dataSystem.connectionModeConfig) { + const connMode = options.configuration.dataSystem.connectionModeConfig; dataSystem.automaticModeSwitching = connMode.initialConnectionMode ? { type: 'manual', initialConnectionMode: connMode.initialConnectionMode } : false; @@ -114,20 +99,20 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { Object.entries(connMode.customConnectionModes).forEach(([modeName, modeDef]) => { connectionModes[modeName] = translateModeDefinition(modeDef); - // Per-entry endpoint overrides also set global URIs for ServiceEndpoints - // compatibility. These override the serviceEndpoints values above. (modeDef.synchronizers ?? []).forEach((sync) => { if (sync.streaming?.baseUri) { - cf.streamUri = sync.streaming.baseUri; - cf.streamInitialReconnectDelay = maybeTime(sync.streaming.initialRetryDelayMs); + fdv2Overrides.streamUri = sync.streaming.baseUri; + fdv2Overrides.streamInitialReconnectDelay = maybeTime( + sync.streaming.initialRetryDelayMs, + ); } if (sync.polling?.baseUri) { - cf.baseUri = sync.polling.baseUri; + fdv2Overrides.baseUri = sync.polling.baseUri; } }); (modeDef.initializers ?? []).forEach((init) => { if (init.polling?.baseUri) { - cf.baseUri = init.polling.baseUri; + fdv2Overrides.baseUri = init.polling.baseUri; } }); }); @@ -135,206 +120,28 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { } } - (cf as any).dataSystem = dataSystem; - } else { - if (options.polling) { - if (options.polling.baseUri) { - cf.baseUri = options.polling.baseUri; - } - } - - if (options.streaming) { - if (options.streaming.baseUri) { - cf.streamUri = options.streaming.baseUri; - } - cf.streaming = true; - cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); - } + fdv2Overrides.dataSystem = dataSystem; + config.set(fdv2Overrides); } - if (options.events) { - if (options.events.baseUri) { - cf.eventsUri = options.events.baseUri; - } - cf.allAttributesPrivate = options.events.allAttributesPrivate; - cf.capacity = options.events.capacity; - cf.diagnosticOptOut = !options.events.enableDiagnostics; - cf.flushInterval = maybeTime(options.events.flushIntervalMs); - cf.privateAttributes = options.events.globalPrivateAttributes; - } else { - cf.sendEvents = false; - } - - if (options.tags) { - cf.applicationInfo = { - id: options.tags.applicationId, - version: options.tags.applicationVersion, - }; - } - - if (options.hooks) { - cf.hooks = options.hooks.hooks.map( - (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), - ); - } - - cf.fetchGoals = false; - - return cf; -} - -function makeDefaultInitialContext() { - return { kind: 'user', key: 'key-not-specified' }; -} - -export class ClientEntity { - constructor( - private readonly _client: LDClient, - private readonly _logger: LDLogger, - ) {} - - close() { - this._client.close(); - this._logger.info('Test ended'); - } - - async doCommand(params: CommandParams) { - this._logger.info(`Received command: ${params.command}`); - switch (params.command) { - case CommandType.EvaluateFlag: { - const evaluationParams = params.evaluate; - if (!evaluationParams) { - throw malformedCommand; - } - if (evaluationParams.detail) { - switch (evaluationParams.valueType) { - case ValueType.Bool: - return this._client.boolVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ); - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return this._client.numberVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ); - case ValueType.String: - return this._client.stringVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ); - default: - return this._client.variationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue, - ); - } - } - switch (evaluationParams.valueType) { - case ValueType.Bool: - return { - value: this._client.boolVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ), - }; - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return { - value: this._client.numberVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ), - }; - case ValueType.String: - return { - value: this._client.stringVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ), - }; - default: - return { - value: this._client.variation( - evaluationParams.flagKey, - evaluationParams.defaultValue, - ), - }; - } - } - - case CommandType.EvaluateAllFlags: - return { state: this._client.allFlags() }; - - case CommandType.IdentifyEvent: { - const identifyParams = params.identifyEvent; - if (!identifyParams) { - throw malformedCommand; - } - await this._client.identify(identifyParams.user || identifyParams.context); - return undefined; - } - - case CommandType.CustomEvent: { - const customEventParams = params.customEvent; - if (!customEventParams) { - throw malformedCommand; - } - this._client.track( - customEventParams.eventKey, - customEventParams.data, - customEventParams.metricValue, - ); - return undefined; - } - - case CommandType.FlushEvents: - this._client.flush(); - return undefined; - - default: - throw badCommandError; - } - } -} - -export async function newSdkClientEntity(options: CreateInstanceParams) { - const logger = makeLogger(options.tag); - - logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); + const sdkConfig = config.build() as LDOptions; + const client = createClient(config.credential, config.initialContext as LDContext, sdkConfig); - const timeout = - options.configuration.startWaitTimeMs !== null && - options.configuration.startWaitTimeMs !== undefined - ? options.configuration.startWaitTimeMs - : 5000; - const sdkConfig = makeSdkConfig(options.configuration, options.tag); - const initialContext = - options.configuration.clientSide?.initialUser || - options.configuration.clientSide?.initialContext || - makeDefaultInitialContext(); - const client = createClient( - options.configuration.credential || 'unknown-env-id', - initialContext, - sdkConfig, - ); let failed = false; try { await Promise.race([ client.start(), new Promise((_resolve, reject) => { - setTimeout(reject, timeout); + setTimeout(reject, config.timeout); }), ]); } catch (_) { - // we get here if waitForInitialization() rejects or if we timed out failed = true; } - if (failed && !options.configuration.initCanFail) { + if (failed && !config.initCanFail) { client.close(); throw new Error('client initialization failed'); } - return new ClientEntity(client, logger); + return new ClientEntity(client, config.tag); } diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 6f82d0c5ea..c92c11a209 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -1,96 +1,38 @@ -import { LDLogger } from '@launchdarkly/js-client-sdk'; -import { makeLogger } 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'); - - 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-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-per-context-summaries', - 'track-hooks', - ]; - - 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); - }; - } - - disconnect() { - this._ws?.close(); - } - - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); - } +import { + Capability, + IClientEntity, + TestHarnessWebSocketBuilder, +} from '@launchdarkly/js-contract-test-utils/client'; + +import { newSdkClientEntity } from './ClientEntity'; + +const CAPABILITIES: Capability[] = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', +]; + +export function createTestHarnessWebSocket() { + const entities = new Map(); + + return new TestHarnessWebSocketBuilder() + .setCapabilities(CAPABILITIES) + .onCreateClient(async (id, params) => { + const entity = await newSdkClientEntity(id, params); + entities.set(id, entity); + return entity; + }) + .onGetClient((id) => entities.get(id)) + .onDeleteClient((id) => { + entities.get(id)?.close(); + entities.delete(id); + }) + .build(); } diff --git a/packages/sdk/browser/contract-tests/entity/src/main.ts b/packages/sdk/browser/contract-tests/entity/src/main.ts index 9869306e36..8395891b17 100644 --- a/packages/sdk/browser/contract-tests/entity/src/main.ts +++ b/packages/sdk/browser/contract-tests/entity/src/main.ts @@ -1,9 +1,9 @@ // eslint-disable-next-line prettier/prettier import './style.css'; -import TestHarnessWebSocket from './TestHarnessWebSocket'; +import { createTestHarnessWebSocket } from './TestHarnessWebSocket'; async function runContractTests() { - const ws = new TestHarnessWebSocket('ws://localhost:8001'); + const ws = createTestHarnessWebSocket(); ws.connect(); } diff --git a/packages/sdk/react-native/contract-tests/entity/App.tsx b/packages/sdk/react-native/contract-tests/entity/App.tsx index 0293facee3..4440e0d2c3 100644 --- a/packages/sdk/react-native/contract-tests/entity/App.tsx +++ b/packages/sdk/react-native/contract-tests/entity/App.tsx @@ -1,7 +1,27 @@ import React, { useEffect, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import TestHarnessWebSocket from './src/TestHarnessWebSocket'; +import { + Capability, + IClientEntity, + TestHarnessWebSocketBuilder, +} from '@launchdarkly/js-contract-test-utils/client'; + +import { newSdkClientEntity } from './src/ClientEntity'; + +const RN_CAPABILITIES: Capability[] = [ + 'client-side', + 'mobile', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', +]; const styles = StyleSheet.create({ container: { @@ -24,7 +44,21 @@ export default function App() { const [connected, setConnected] = useState(false); useEffect(() => { - const ws = new TestHarnessWebSocket('ws://localhost:8001', setConnected); + const entities = new Map(); + const ws = new TestHarnessWebSocketBuilder() + .setCapabilities(RN_CAPABILITIES) + .onCreateClient(async (id, params) => { + const entity = await newSdkClientEntity(id, params); + entities.set(id, entity); + return entity; + }) + .onGetClient((id) => entities.get(id)) + .onDeleteClient((id) => { + entities.get(id)?.close(); + entities.delete(id); + }) + .onConnectionChange(setConnected) + .build(); ws.connect(); return () => ws.disconnect(); }, []); diff --git a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts index 19d87944d9..9bb8e75af5 100644 --- a/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/react-native/contract-tests/entity/src/ClientEntity.ts @@ -1,250 +1,55 @@ import { - CommandParams, - CommandType, + ClientEntity, + ConfigBuilder, CreateInstanceParams, - makeLogger, - SDKConfigParams, - ClientSideTestHook as TestHook, - ValueType, + IClientEntity, } from '@launchdarkly/js-contract-test-utils/client'; import { AutoEnvAttributes, + LDContext, LDOptions, ReactNativeLDClient, } from '@launchdarkly/react-native-client-sdk'; -export const badCommandError = new Error('unsupported command'); -export const malformedCommand = new Error('command was malformed'); - -function makeSdkConfig(options: SDKConfigParams, tag: string) { - if (!options.clientSide) { - throw new Error('configuration did not include clientSide options'); - } - - const isSet = (x?: unknown) => x !== null && x !== undefined; - const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds / 1000 : undefined); - - const cf: LDOptions = { - withReasons: options.clientSide.evaluationReasons, - logger: makeLogger(`${tag}.sdk`), - useReport: options.clientSide.useReport, - // Disable automatic lifecycle handling for contract tests +export async function newSdkClientEntity( + _id: string, + options: CreateInstanceParams, +): Promise { + const config = new ConfigBuilder(options).omit('streaming').set({ automaticNetworkHandling: false, automaticBackgroundHandling: false, - }; - - if (options.serviceEndpoints) { - cf.streamUri = options.serviceEndpoints.streaming; - cf.baseUri = options.serviceEndpoints.polling; - cf.eventsUri = options.serviceEndpoints.events; - } - - if (options.polling) { - if (options.polling.baseUri) { - cf.baseUri = options.polling.baseUri; - } - cf.initialConnectionMode = 'polling'; - } - - // Can contain streaming and polling, if streaming is set override the initial connection - // mode. - if (options.streaming) { - if (options.streaming.baseUri) { - cf.streamUri = options.streaming.baseUri; - } - cf.initialConnectionMode = 'streaming'; - cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); - } - - if (options.events) { - if (options.events.baseUri) { - cf.eventsUri = options.events.baseUri; - } - cf.allAttributesPrivate = options.events.allAttributesPrivate; - cf.capacity = options.events.capacity; - cf.diagnosticOptOut = !options.events.enableDiagnostics; - cf.flushInterval = maybeTime(options.events.flushIntervalMs); - cf.privateAttributes = options.events.globalPrivateAttributes; - } else { - cf.sendEvents = false; - } - - if (options.tags) { - cf.applicationInfo = { - id: options.tags.applicationId, - version: options.tags.applicationVersion, - }; - } - - if (options.hooks) { - cf.hooks = options.hooks.hooks.map( - (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), - ); - } - - return cf; -} - -function makeDefaultInitialContext() { - return { kind: 'user', key: 'key-not-specified' }; -} - -export class ClientEntity { - constructor( - private readonly _client: ReactNativeLDClient, - private readonly _logger: ReturnType, - ) {} - - close() { - this._client.close(); - this._logger.info('Test ended'); - } - - async doCommand(params: CommandParams) { - this._logger.info(`Received command: ${params.command}`); - switch (params.command) { - case CommandType.EvaluateFlag: { - const evaluationParams = params.evaluate; - if (!evaluationParams) { - throw malformedCommand; - } - if (evaluationParams.detail) { - switch (evaluationParams.valueType) { - case ValueType.Bool: - return this._client.boolVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ); - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return this._client.numberVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ); - case ValueType.String: - return this._client.stringVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ); - default: - return this._client.variationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue, - ); - } - } - switch (evaluationParams.valueType) { - case ValueType.Bool: - return { - value: this._client.boolVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ), - }; - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return { - value: this._client.numberVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ), - }; - case ValueType.String: - return { - value: this._client.stringVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ), - }; - default: - return { - value: this._client.variation( - evaluationParams.flagKey, - evaluationParams.defaultValue, - ), - }; - } - } - - case CommandType.EvaluateAllFlags: - return { state: this._client.allFlags() }; - - case CommandType.IdentifyEvent: { - const identifyParams = params.identifyEvent; - if (!identifyParams) { - throw malformedCommand; - } - await this._client.identify(identifyParams.user || identifyParams.context); - return undefined; - } - - case CommandType.CustomEvent: { - const customEventParams = params.customEvent; - if (!customEventParams) { - throw malformedCommand; - } - this._client.track( - customEventParams.eventKey, - customEventParams.data, - customEventParams.metricValue, - ); - return undefined; - } - - case CommandType.FlushEvents: - await this._client.flush(); - return undefined; - - default: - throw badCommandError; - } - } -} - -export async function newSdkClientEntity(options: CreateInstanceParams) { - const logger = makeLogger(options.tag); - - logger.info(`Creating client with configuration: ${JSON.stringify(options.configuration)}`); - - const timeout = - options.configuration.startWaitTimeMs !== null && - options.configuration.startWaitTimeMs !== undefined - ? options.configuration.startWaitTimeMs - : 5000; - - const sdkConfig = makeSdkConfig(options.configuration, options.tag); + ...(options.configuration.polling && { initialConnectionMode: 'polling' }), + ...(options.configuration.streaming && { initialConnectionMode: 'streaming' }), + }); const autoEnvAttributes = options.configuration.clientSide?.includeEnvironmentAttributes ? AutoEnvAttributes.Enabled : AutoEnvAttributes.Disabled; - const initialContext = - options.configuration.clientSide?.initialUser || - options.configuration.clientSide?.initialContext || - makeDefaultInitialContext(); - const client = new ReactNativeLDClient( options.configuration.credential || 'unknown-mobile-key', autoEnvAttributes, - sdkConfig, + config.build() as LDOptions, ); let failed = false; try { await Promise.race([ - client.identify(initialContext, { timeout: timeout / 1000, waitForNetworkResults: true }), + client.identify(config.initialContext as LDContext, { + timeout: config.timeout / 1000, + waitForNetworkResults: true, + }), new Promise((_resolve, reject) => { - setTimeout(reject, timeout); + setTimeout(reject, config.timeout); }), ]); } catch (_) { - // we get here if identify() rejects or if we timed out failed = true; } - if (failed && !options.configuration.initCanFail) { + if (failed && !config.initCanFail) { client.close(); throw new Error('client initialization failed'); } - return new ClientEntity(client, logger); + return new ClientEntity(client, config.tag); } diff --git a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts deleted file mode 100644 index 24cd7cff2b..0000000000 --- a/packages/sdk/react-native/contract-tests/entity/src/TestHarnessWebSocket.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { makeLogger } from '@launchdarkly/js-contract-test-utils/client'; -import { LDLogger } from '@launchdarkly/react-native-client-sdk'; - -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 = [ - 'client-side', - 'mobile', - 'service-endpoints', - 'tags', - 'user-type', - 'inline-context-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-per-context-summaries', - 'track-hooks', - ]; - - 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}`); - } - - 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); - }; - } - - disconnect() { - this._intentionalClose = true; - this._ws?.close(); - } - - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); - } -} diff --git a/packages/sdk/react/contract-tests/app/ClientEntity.ts b/packages/sdk/react/contract-tests/app/ClientEntity.ts index 8b989e6b2a..866a084e92 100644 --- a/packages/sdk/react/contract-tests/app/ClientEntity.ts +++ b/packages/sdk/react/contract-tests/app/ClientEntity.ts @@ -4,182 +4,32 @@ import { useEffect } from 'react'; import { CommandParams, - CommandType, - makeLogger, - SDKConfigParams, - ClientSideTestHook as TestHook, - ValueType, + doCommand, + IClientEntity, } from '@launchdarkly/js-contract-test-utils/client'; -import { LDOptions, LDReactClient, useLDClient } from '@launchdarkly/react-sdk'; +import { useLDClient } from '@launchdarkly/react-sdk'; -export const badCommandError = new Error('unsupported command'); -export const malformedCommand = new Error('command was malformed'); - -export function makeSdkConfig(options: SDKConfigParams, tag: string): LDOptions { - if (!options.clientSide) { - throw new Error('configuration did not include clientSide options'); - } - - const isSet = (x?: unknown) => x !== null && x !== undefined; - const maybeTime = (seconds?: number) => (isSet(seconds) ? seconds! / 1000 : undefined); - - const cf: LDOptions = { - withReasons: options.clientSide.evaluationReasons, - logger: makeLogger(`${tag}.sdk`), - useReport: options.clientSide.useReport, - }; - - if (options.serviceEndpoints) { - cf.streamUri = options.serviceEndpoints.streaming; - cf.baseUri = options.serviceEndpoints.polling; - cf.eventsUri = options.serviceEndpoints.events; - } - - if (options.polling) { - if (options.polling.baseUri) { - cf.baseUri = options.polling.baseUri; - } - } - - if (options.streaming) { - if (options.streaming.baseUri) { - cf.streamUri = options.streaming.baseUri; - } - cf.streaming = true; - cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); - } - - if (options.events) { - if (options.events.baseUri) { - cf.eventsUri = options.events.baseUri; - } - cf.allAttributesPrivate = options.events.allAttributesPrivate; - cf.capacity = options.events.capacity; - cf.diagnosticOptOut = !options.events.enableDiagnostics; - cf.flushInterval = maybeTime(options.events.flushIntervalMs); - cf.privateAttributes = options.events.globalPrivateAttributes; - } else { - cf.sendEvents = false; - } - - if (options.tags) { - cf.applicationInfo = { - id: options.tags.applicationId, - version: options.tags.applicationVersion, - }; - } - - if (options.hooks) { - cf.hooks = options.hooks.hooks.map( - (hook) => new TestHook(hook.name, hook.callbackUri, hook.data, hook.errors), - ); - } - - cf.fetchGoals = false; - - return cf; -} - -// NOTE: we can make this more idiomatic to React by creating a useCommand hook and reading return values from -// a rendered component. But for now, this is just a simple way to get the tests running. -export async function doCommand(client: LDReactClient, params: CommandParams): Promise { - const logger = makeLogger('doCommand'); - logger.info(`Received command: ${params.command}`); - - switch (params.command) { - case CommandType.EvaluateFlag: { - const evaluationParams = params.evaluate; - if (!evaluationParams) { - throw malformedCommand; - } - if (evaluationParams.detail) { - switch (evaluationParams.valueType) { - case ValueType.Bool: - return client.boolVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ); - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return client.numberVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ); - case ValueType.String: - return client.stringVariationDetail( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ); - default: - return client.variationDetail(evaluationParams.flagKey, evaluationParams.defaultValue); - } - } - switch (evaluationParams.valueType) { - case ValueType.Bool: - return { - value: client.boolVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as boolean, - ), - }; - case ValueType.Int: // Intentional fallthrough. - case ValueType.Double: - return { - value: client.numberVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as number, - ), - }; - case ValueType.String: - return { - value: client.stringVariation( - evaluationParams.flagKey, - evaluationParams.defaultValue as string, - ), - }; - default: - return { - value: client.variation(evaluationParams.flagKey, evaluationParams.defaultValue), - }; - } - } - - case CommandType.EvaluateAllFlags: - return { state: client.allFlags() }; - - case CommandType.IdentifyEvent: { - const identifyParams = params.identifyEvent; - if (!identifyParams) { - throw malformedCommand; - } - await client.identify(identifyParams.user || identifyParams.context); - return undefined; - } +export type CommandHandler = (params: CommandParams) => Promise; - case CommandType.CustomEvent: { - const customEventParams = params.customEvent; - if (!customEventParams) { - throw malformedCommand; +// We are creating this wrapper because we have a custom commandHandler implementation to work well +// with React rendering. +export function createReactClientEntity( + clientId: string, + commandHandlers: Map, + close: () => void, +): IClientEntity { + return { + doCommand: async (params: CommandParams) => { + const handler = commandHandlers.get(clientId); + if (!handler) { + throw new Error(`No command handler registered for client ${clientId}`); } - client.track( - customEventParams.eventKey, - customEventParams.data, - customEventParams.metricValue, - ); - return undefined; - } - - case CommandType.FlushEvents: - client.flush(); - return undefined; - - default: - throw badCommandError; - } + return handler(params); + }, + close, + }; } -export type CommandHandler = (params: CommandParams) => Promise; - export function ClientInstance({ clientId, handlers, diff --git a/packages/sdk/react/contract-tests/app/ClientRoot.tsx b/packages/sdk/react/contract-tests/app/ClientRoot.tsx index 4dd03c6946..222c192112 100644 --- a/packages/sdk/react/contract-tests/app/ClientRoot.tsx +++ b/packages/sdk/react/contract-tests/app/ClientRoot.tsx @@ -2,15 +2,21 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { CreateInstanceParams } from '@launchdarkly/js-contract-test-utils/client'; +import { + Capability, + ConfigBuilder, + IClientEntity, + TestHarnessWebSocketBuilder, +} from '@launchdarkly/js-contract-test-utils/client'; import { createClient, createLDReactProviderWithClient, + LDContext, + LDOptions, LDReactClient, } from '@launchdarkly/react-sdk'; -import { ClientInstance, CommandHandler, makeSdkConfig } from './ClientEntity'; -import TestHarnessWebSocket from './TestHarnessWebSocket'; +import { ClientInstance, CommandHandler, createReactClientEntity } from './ClientEntity'; interface ClientRecord { id: string; @@ -18,54 +24,45 @@ interface ClientRecord { Provider: React.FC<{ children: React.ReactNode }>; } +const CAPABILITIES: Capability[] = [ + 'client-side', + 'service-endpoints', + 'tags', + 'user-type', + 'inline-context-all', + 'anonymous-redaction', + 'strongly-typed', + 'client-prereq-events', + 'client-per-context-summaries', + 'track-hooks', +]; + export default function ClientRoot({ children }: { children: React.ReactNode }) { - // Keeps a list of all the clients that we have, we will need to keep this as a state - // to ensure that the ld client providers are being rendered. const [clients, setClients] = useState([]); const commandHandlers = useRef(new Map()); const handlerReadyMap = useRef(new Map void>()); - const clientCounterRef = useRef(0); const clientsRef = useRef([]); + const entityMap = useRef(new Map()); useEffect(() => { - const ws = new TestHarnessWebSocket( - 'ws://localhost:8001', - commandHandlers.current, - - // On create client - async (params: CreateInstanceParams) => { - const id = String(clientCounterRef.current); - clientCounterRef.current += 1; - - const timeout = - params.configuration.startWaitTimeMs !== null && - params.configuration.startWaitTimeMs !== undefined - ? params.configuration.startWaitTimeMs - : 5000; - - const sdkConfig = makeSdkConfig(params.configuration, params.tag); - const initialContext = params.configuration.clientSide?.initialUser || - params.configuration.clientSide?.initialContext || { - kind: 'user', - key: 'key-not-specified', - }; + const ws = new TestHarnessWebSocketBuilder() + .setCapabilities(CAPABILITIES) + .onCreateClient(async (id, params) => { + const config = new ConfigBuilder(params).set({ fetchGoals: false }); const client = createClient( - params.configuration.credential || 'unknown-env-id', - initialContext, - sdkConfig, + config.credential, + config.initialContext as LDContext, + config.build() as LDOptions, ); - const { status } = await client.start({ timeout: timeout / 1000 }); + const { status } = await client.start({ timeout: config.timeout / 1000 }); - if (status === 'failed' && !params.configuration.initCanFail) { + if (status === 'failed' && !config.initCanFail) { client.close(); throw new Error('client initialization failed'); } - // Currently these tests are creating the provider with a custom ld client, which is a - // supported feature, but I think it would be better if we can use the create provider - // factory function instead. const Provider = createLDReactProviderWithClient(client); const handlerReady = new Promise((resolve) => { @@ -77,16 +74,19 @@ export default function ClientRoot({ children }: { children: React.ReactNode }) await handlerReady; handlerReadyMap.current.delete(id); - return id; - }, - // On delete client - (id: string) => { + const entity = createReactClientEntity(id, commandHandlers.current, () => client.close()); + entityMap.current.set(id, entity); + return entity; + }) + .onGetClient((id) => entityMap.current.get(id)) + .onDeleteClient((id) => { + entityMap.current.delete(id); clientsRef.current.find((r) => r.id === id)?.client.close(); clientsRef.current = clientsRef.current.filter((r) => r.id !== id); setClients((prev) => prev.filter((r) => r.id !== id)); - }, - ); + }) + .build(); ws.connect(); return () => ws.disconnect(); diff --git a/packages/sdk/react/contract-tests/app/TestHarnessWebSocket.ts b/packages/sdk/react/contract-tests/app/TestHarnessWebSocket.ts deleted file mode 100644 index 8d0ae51f8f..0000000000 --- a/packages/sdk/react/contract-tests/app/TestHarnessWebSocket.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { CreateInstanceParams, makeLogger } from '@launchdarkly/js-contract-test-utils/client'; -import { LDLogger } from '@launchdarkly/react-sdk'; - -import { CommandHandler } from './ClientEntity'; - -export default class TestHarnessWebSocket { - private _ws?: WebSocket; - private _logger: LDLogger = makeLogger('TestHarnessWebSocket'); - - constructor( - private readonly _url: string, - private readonly _commandHandlers: Map, - private readonly _onCreateClient: (params: CreateInstanceParams) => Promise, - private readonly _onDeleteClient: (id: string) => void, - ) {} - - 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-all', - 'anonymous-redaction', - 'strongly-typed', - 'client-prereq-events', - 'client-per-context-summaries', - 'track-hooks', - ]; - break; - - case 'createClient': { - try { - const id = await this._onCreateClient(data.body); - resData.resourceUrl = `/clients/${id}`; - resData.status = 201; - } catch (e: any) { - resData.status = 500; - resData.error = e?.message ?? String(e); - } - break; - } - - case 'runCommand': { - const handler = this._commandHandlers.get(data.id); - if (handler) { - try { - const body = await handler(data.body); - resData.body = body; - resData.status = body ? 200 : 204; - } catch (e: any) { - if (e?.message === 'unsupported command') { - resData.status = 400; - resData.error = e.message; - } else { - resData.status = 500; - resData.error = e?.message ?? String(e); - } - } - } else { - resData.status = 404; - this._logger.warn(`Client did not exist: ${data.id}`); - } - break; - } - - case 'deleteClient': - this._onDeleteClient(data.id); - resData.status = 200; - break; - - default: - break; - } - - this.send(resData); - }; - } - - disconnect() { - this._ws?.close(); - } - - send(data: unknown) { - this._ws?.send(JSON.stringify(data)); - } -} diff --git a/packages/tooling/contract-test-utils/src/client-side/ClientEntity.ts b/packages/tooling/contract-test-utils/src/client-side/ClientEntity.ts new file mode 100644 index 0000000000..61b4aff15b --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/ClientEntity.ts @@ -0,0 +1,30 @@ +import { makeLogger } from '../logging/makeLogger.js'; +import { CommandParams } from '../types/CommandParams.js'; +import { LDLogger } from '../types/compat.js'; +import { CommandableClient } from './CommandableClient.js'; +import { doCommand as doCommandFn } from './doCommand.js'; +import { IClientEntity } from './TestHarnessWebSocket.js'; + +/** + * Base client entity that wraps an SDK client and dispatches test harness + * commands. + */ +export class ClientEntity implements IClientEntity { + protected readonly logger: LDLogger; + protected readonly client: CommandableClient; + + constructor(client: CommandableClient, tag: string) { + this.client = client; + this.logger = makeLogger(tag); + } + + close() { + this.client.close(); + this.logger.info('Test ended'); + } + + async doCommand(params: CommandParams): Promise { + this.logger.info(`Received command: ${params.command}`); + return doCommandFn(this.client, params); + } +} diff --git a/packages/tooling/contract-test-utils/src/client-side/CommandableClient.ts b/packages/tooling/contract-test-utils/src/client-side/CommandableClient.ts new file mode 100644 index 0000000000..5d21f5c4b2 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/CommandableClient.ts @@ -0,0 +1,33 @@ +import { LDContext, LDEvaluationReason } from '../types/compat.js'; + +/** + * Structural interface that should satisfy all client-side SDKs. This is + * used to dispatch commands from the webhook. + */ +export interface CommandableClient { + boolVariation(flagKey: string, defaultValue: boolean): boolean; + boolVariationDetail( + flagKey: string, + defaultValue: boolean, + ): { value: boolean; variationIndex?: number | null; reason?: LDEvaluationReason }; + numberVariation(flagKey: string, defaultValue: number): number; + numberVariationDetail( + flagKey: string, + defaultValue: number, + ): { value: number; variationIndex?: number | null; reason?: LDEvaluationReason }; + stringVariation(flagKey: string, defaultValue: string): string; + stringVariationDetail( + flagKey: string, + defaultValue: string, + ): { value: string; variationIndex?: number | null; reason?: LDEvaluationReason }; + variation(flagKey: string, defaultValue: unknown): unknown; + variationDetail( + flagKey: string, + defaultValue: unknown, + ): { value: unknown; variationIndex?: number | null; reason?: LDEvaluationReason }; + identify(context: LDContext): Promise; + track(eventKey: string, data?: unknown, metricValue?: number): void; + flush(): void | Promise; + allFlags(): Record; + close(): void; +} diff --git a/packages/tooling/contract-test-utils/src/client-side/ConfigBuilder.ts b/packages/tooling/contract-test-utils/src/client-side/ConfigBuilder.ts new file mode 100644 index 0000000000..52ecedbbca --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/ConfigBuilder.ts @@ -0,0 +1,185 @@ +import { makeLogger } from '../logging/makeLogger.js'; +import { LDLogger } from '../types/compat.js'; +import { CreateInstanceParams, SDKConfigParams } from '../types/ConfigParams.js'; +import ClientSideTestHook from './TestHook.js'; + +/** + * Base SDK config type produced by ConfigBuilder.build(). + * Contains fields common to all client-side SDKs. Consumers cast this + * to their platform-specific LDOptions type. + */ +export interface ClientSideSdkConfig { + withReasons?: boolean; + logger?: LDLogger; + useReport?: boolean; + streamUri?: string; + baseUri?: string; + eventsUri?: string; + streaming?: boolean; + streamInitialReconnectDelay?: number; + sendEvents?: boolean; + allAttributesPrivate?: boolean; + capacity?: number; + diagnosticOptOut?: boolean; + flushInterval?: number; + privateAttributes?: string[]; + applicationInfo?: { id?: string; version?: string }; + hooks?: unknown[]; + [key: string]: unknown; +} + +export type ConfigSection = + | 'serviceEndpoints' + | 'streaming' + | 'polling' + | 'events' + | 'tags' + | 'hooks'; + +/** + * Config adapter for client-side contract test entities. + * + * Takes raw {@link CreateInstanceParams} from the test harness and provides: + * an adapted SDK config object that could be used in our contract tests. + * + * ```typescript + * const config = new ConfigBuilder(options) + * .skip('streaming') + * .set({ fetchGoals: false }); + * + * const client = createClient(config.credential, config.initialContext, config.build()); + * await client.start({ timeout: config.timeout }); + * ``` + */ +export class ConfigBuilder { + private _configuration: SDKConfigParams; + private _tag: string; + private _skippedSections: Set = new Set(); + private _omittedKeys: Set = new Set(); + private _overrides: Record = {}; + + constructor(options: CreateInstanceParams) { + this._configuration = options.configuration; + this._tag = options.tag; + } + + get timeout(): number { + const ms = this._configuration.startWaitTimeMs; + return ms !== null && ms !== undefined ? ms : 5000; + } + + get initialContext(): Record { + return ( + this._configuration.clientSide?.initialUser || + this._configuration.clientSide?.initialContext || { kind: 'user', key: 'key-not-specified' } + ); + } + + get credential(): string { + return this._configuration.credential || 'unknown-env-id'; + } + + get initCanFail(): boolean { + return this._configuration.initCanFail ?? false; + } + + get tag(): string { + return this._tag; + } + + get configuration(): SDKConfigParams { + return this._configuration; + } + + // -- Builder methods -- + + /** Skip one or more config sections — they won't appear in the output. */ + skip(...sections: ConfigSection[]): this { + sections.forEach((s) => this._skippedSections.add(s)); + return this; + } + + /** Remove specific keys from the built output. Unlike skip(), the section + * still runs — only the named keys are deleted from the result. */ + omit(...keys: string[]): this { + keys.forEach((k) => this._omittedKeys.add(k)); + return this; + } + + /** Add or override fields on the final config object. Applied last. */ + set(values: Record): this { + Object.assign(this._overrides, values); + return this; + } + + /** Build the final SDK config object. */ + build(): ClientSideSdkConfig { + if (!this._configuration.clientSide) { + throw new Error('configuration did not include clientSide options'); + } + + const cf: ClientSideSdkConfig = { + withReasons: this._configuration.clientSide.evaluationReasons, + logger: makeLogger(`${this._tag}.sdk`), + useReport: this._configuration.clientSide.useReport, + }; + + if (!this._skippedSections.has('serviceEndpoints') && this._configuration.serviceEndpoints) { + cf.streamUri = this._configuration.serviceEndpoints.streaming; + cf.baseUri = this._configuration.serviceEndpoints.polling; + cf.eventsUri = this._configuration.serviceEndpoints.events; + } + + if (!this._skippedSections.has('polling') && this._configuration.polling) { + if (this._configuration.polling.baseUri) { + cf.baseUri = this._configuration.polling.baseUri; + } + } + + if (!this._skippedSections.has('streaming') && this._configuration.streaming) { + if (this._configuration.streaming.baseUri) { + cf.streamUri = this._configuration.streaming.baseUri; + } + cf.streaming = true; + cf.streamInitialReconnectDelay = this._maybeTime( + this._configuration.streaming.initialRetryDelayMs, + ); + } + + if (!this._skippedSections.has('events')) { + if (this._configuration.events) { + if (this._configuration.events.baseUri) { + cf.eventsUri = this._configuration.events.baseUri; + } + cf.allAttributesPrivate = this._configuration.events.allAttributesPrivate; + cf.capacity = this._configuration.events.capacity; + cf.diagnosticOptOut = !this._configuration.events.enableDiagnostics; + cf.flushInterval = this._maybeTime(this._configuration.events.flushIntervalMs); + cf.privateAttributes = this._configuration.events.globalPrivateAttributes; + } else { + cf.sendEvents = false; + } + } + + if (!this._skippedSections.has('tags') && this._configuration.tags) { + cf.applicationInfo = { + id: this._configuration.tags.applicationId, + version: this._configuration.tags.applicationVersion, + }; + } + + if (!this._skippedSections.has('hooks') && this._configuration.hooks) { + cf.hooks = this._configuration.hooks.hooks.map( + (hook) => new ClientSideTestHook(hook.name, hook.callbackUri, hook.data, hook.errors), + ); + } + + const result: ClientSideSdkConfig = { ...cf, ...this._overrides }; + this._omittedKeys.forEach((key) => delete result[key]); + return result; + } + + private _maybeTime(ms?: number): number | undefined { + return ms !== null && ms !== undefined ? ms / 1000 : undefined; + } +} diff --git a/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts new file mode 100644 index 0000000000..0de20b9f71 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/TestHarnessWebSocket.ts @@ -0,0 +1,189 @@ +/* eslint-disable max-classes-per-file */ +import { makeLogger } from '../logging/makeLogger.js'; +import { CommandParams } from '../types/CommandParams.js'; +import { LDLogger } from '../types/compat.js'; +import { CreateInstanceParams } from '../types/ConfigParams.js'; +import { Capability } from './capabilities.js'; + +export interface IClientEntity { + doCommand(params: CommandParams): Promise; + close(): void; +} + +type CreateClientFn = (id: string, params: CreateInstanceParams) => Promise; +type DeleteClientFn = (id: string) => void; +type GetClientFn = (id: string) => IClientEntity | undefined; +type ConnectionChangeFn = (connected: boolean) => void; + +class TestHarnessWebSocket { + private _ws?: WebSocket; + private _logger: LDLogger; + private _intentionalClose = false; + + constructor( + private readonly _url: string, + private readonly _capabilities: Capability[], + private readonly _createClient: CreateClientFn, + private readonly _deleteClient: DeleteClientFn, + private readonly _getClient: GetClientFn, + private readonly _onConnectionChange?: ConnectionChangeFn, + ) { + this._logger = makeLogger('TestHarnessWebSocket'); + } + + 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(typeof msg.data === 'string' ? msg.data : String(msg.data)); + const resData: any = { reqId: data.reqId }; + + switch (data.command) { + case 'getCapabilities': + resData.capabilities = [...this._capabilities]; + break; + + case 'createClient': { + const id = String(this._clientCounter); + this._clientCounter += 1; + try { + await this._createClient(id, data.body); + resData.resourceUrl = `/clients/${id}`; + resData.status = 201; + } catch (e: any) { + resData.status = 500; + resData.error = e?.message ?? String(e); + } + break; + } + + case 'runCommand': { + const entity = this._getClient(data.id); + if (entity) { + try { + const body = await entity.doCommand(data.body); + resData.body = body; + resData.status = body ? 200 : 204; + } catch (e: any) { + if (e?.message === 'unsupported command') { + resData.status = 400; + resData.error = e.message; + } else { + resData.status = 500; + resData.error = e?.message ?? String(e); + } + } + } else { + resData.status = 404; + this._logger.warn(`Client did not exist: ${data.id}`); + } + break; + } + + case 'deleteClient': + this._deleteClient(data.id); + resData.status = 200; + break; + + default: + break; + } + + this.send(resData); + }; + } + + disconnect() { + this._intentionalClose = true; + this._ws?.close(); + } + + send(data: unknown) { + this._ws?.send(JSON.stringify(data)); + } + + private _clientCounter = 0; +} + +/** + * Creates a TestHarnessWebSocket instance that is compatible with the + * @launchdarkly/js-contract-test-utils/adapter tool. + */ +export class TestHarnessWebSocketBuilder { + private _url = 'ws://localhost:8001'; + private _capabilities: Capability[] = []; + private _createClient?: CreateClientFn; + private _deleteClient?: DeleteClientFn; + private _getClient?: GetClientFn; + private _connectionChange?: ConnectionChangeFn; + + setUrl(url: string): this { + this._url = url; + return this; + } + + setCapabilities(capabilities: Capability[]): this { + this._capabilities = capabilities; + return this; + } + + onCreateClient(fn: CreateClientFn): this { + this._createClient = fn; + return this; + } + + onDeleteClient(fn: DeleteClientFn): this { + this._deleteClient = fn; + return this; + } + + onGetClient(fn: GetClientFn): this { + this._getClient = fn; + return this; + } + + onConnectionChange(fn: ConnectionChangeFn): this { + this._connectionChange = fn; + return this; + } + + build(): TestHarnessWebSocket { + if (!this._createClient) { + throw new Error('onCreateClient is required'); + } + if (!this._deleteClient) { + throw new Error('onDeleteClient is required'); + } + if (!this._getClient) { + throw new Error('onGetClient is required'); + } + + return new TestHarnessWebSocket( + this._url, + this._capabilities, + this._createClient, + this._deleteClient, + this._getClient, + this._connectionChange, + ); + } +} diff --git a/packages/tooling/contract-test-utils/src/client-side/capabilities.ts b/packages/tooling/contract-test-utils/src/client-side/capabilities.ts new file mode 100644 index 0000000000..41ae4636d9 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/capabilities.ts @@ -0,0 +1,46 @@ +/** + * Known capability strings from the SDK test harness service spec. + * See: https://github.com/launchdarkly/sdk-test-harness/blob/v2/docs/service_spec.md + */ +export type Capability = + | 'server-side' + | 'client-side' + | 'mobile' + | 'php' + | 'singleton' + | 'strongly-typed' + | 'all-flags-with-reasons' + | 'all-flags-client-side-only' + | 'all-flags-details-only-for-tracked-flags' + | 'anonymous-redaction' + | 'auto-env-attributes' + | 'big-segments' + | 'client-independence' + | 'client-prereq-events' + | 'client-per-context-summaries' + | 'context-type' + | 'context-comparison' + | 'etag-caching' + | 'event-gzip' + | 'optional-event-gzip' + | 'event-sampling' + | 'evaluation-hooks' + | 'filtering' + | 'filtering-strict' + | 'http-proxy' + | 'inline-context' + | 'inline-context-all' + | 'instance-id' + | 'migrations' + | 'omit-anonymous-contexts' + | 'polling-gzip' + | 'secure-mode-hash' + | 'server-side-polling' + | 'service-endpoints' + | 'tags' + | 'tls:verify-peer' + | 'tls:skip-verify-peer' + | 'tls:custom-ca' + | 'track-hooks' + | 'user-type' + | 'wrapper'; diff --git a/packages/tooling/contract-test-utils/src/client-side/doCommand.ts b/packages/tooling/contract-test-utils/src/client-side/doCommand.ts new file mode 100644 index 0000000000..dadb86d860 --- /dev/null +++ b/packages/tooling/contract-test-utils/src/client-side/doCommand.ts @@ -0,0 +1,109 @@ +import { CommandParams, CommandType, ValueType } from '../types/CommandParams.js'; +import { CommandableClient } from './CommandableClient.js'; + +export const badCommandError = new Error('unsupported command'); +export const malformedCommand = new Error('command was malformed'); + +/** + * Dispatches a test harness command to the appropriate SDK client method. + * Shared across all client-side entities. + * + * NOTE: Maybe in the future we will need to make this more flexible to support + * more complex contract tests scenarios. We can look into creating a builder + * for this this function. + */ +export async function doCommand( + client: CommandableClient, + params: CommandParams, +): Promise { + switch (params.command) { + case CommandType.EvaluateFlag: { + const evaluationParams = params.evaluate; + if (!evaluationParams) { + throw malformedCommand; + } + if (evaluationParams.detail) { + switch (evaluationParams.valueType) { + case ValueType.Bool: + return client.boolVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ); + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return client.numberVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ); + case ValueType.String: + return client.stringVariationDetail( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ); + default: + return client.variationDetail(evaluationParams.flagKey, evaluationParams.defaultValue); + } + } + switch (evaluationParams.valueType) { + case ValueType.Bool: + return { + value: client.boolVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as boolean, + ), + }; + case ValueType.Int: // Intentional fallthrough. + case ValueType.Double: + return { + value: client.numberVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as number, + ), + }; + case ValueType.String: + return { + value: client.stringVariation( + evaluationParams.flagKey, + evaluationParams.defaultValue as string, + ), + }; + default: + return { + value: client.variation(evaluationParams.flagKey, evaluationParams.defaultValue), + }; + } + } + + case CommandType.EvaluateAllFlags: + return { state: client.allFlags() }; + + case CommandType.IdentifyEvent: { + const identifyParams = params.identifyEvent; + if (!identifyParams) { + throw malformedCommand; + } + await client.identify(identifyParams.user || identifyParams.context); + return undefined; + } + + case CommandType.CustomEvent: { + const customEventParams = params.customEvent; + if (!customEventParams) { + throw malformedCommand; + } + client.track( + customEventParams.eventKey, + customEventParams.data, + customEventParams.metricValue, + ); + return undefined; + } + + case CommandType.FlushEvents: + await client.flush(); + return undefined; + + default: + throw badCommandError; + } +} diff --git a/packages/tooling/contract-test-utils/src/client.ts b/packages/tooling/contract-test-utils/src/client.ts index 80469be6b1..30e88678a1 100644 --- a/packages/tooling/contract-test-utils/src/client.ts +++ b/packages/tooling/contract-test-utils/src/client.ts @@ -3,3 +3,11 @@ export * from './index.js'; // Client-side exports export { default as ClientSideTestHook } from './client-side/TestHook.js'; +export type { Capability } from './client-side/capabilities.js'; +export type { CommandableClient } from './client-side/CommandableClient.js'; +export { ClientEntity } from './client-side/ClientEntity.js'; +export { doCommand, badCommandError, malformedCommand } from './client-side/doCommand.js'; +export { ConfigBuilder } from './client-side/ConfigBuilder.js'; +export type { ConfigSection, ClientSideSdkConfig } from './client-side/ConfigBuilder.js'; +export { TestHarnessWebSocketBuilder } from './client-side/TestHarnessWebSocket.js'; +export type { IClientEntity } from './client-side/TestHarnessWebSocket.js'; diff --git a/packages/tooling/contract-test-utils/src/types/compat.ts b/packages/tooling/contract-test-utils/src/types/compat.ts index c673f35786..9f62f65047 100644 --- a/packages/tooling/contract-test-utils/src/types/compat.ts +++ b/packages/tooling/contract-test-utils/src/types/compat.ts @@ -18,7 +18,6 @@ export type LDContext = Record; */ export interface LDEvaluationReason { kind: string; - [key: string]: unknown; } /**