From 29fa0c732809cde554f2d5d1f3d5bf6269b88350 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 24 Feb 2026 09:04:11 -0800 Subject: [PATCH] feat(contract-tests): add flag change listener support for browser SDK Implement contract test support for flag change listeners in the browser SDK test service: - Add 'flag-change-listeners' capability to the service - Handle registerFlagChangeListener, registerFlagValueChangeListener, and unregisterListener commands - Use SDK 'change' and 'change:${key}' events for config and value changes - Post listener callbacks to the test harness via fetch - Unregister listeners on client close - Add dataSystem config parsing to map streaming and polling endpoints from the test harness to SDK options (streamUri, baseUri) --- .../contract-tests/entity/src/ClientEntity.ts | 108 ++++++++++++++++++ .../entity/src/CommandParams.ts | 23 ++++ .../contract-tests/entity/src/ConfigParams.ts | 25 ++++ .../entity/src/TestHarnessWebSocket.ts | 1 + 4 files changed, 157 insertions(+) diff --git a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts index 0d50a90bb8..06f2e9dfb4 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ClientEntity.ts @@ -5,6 +5,11 @@ import { CreateInstanceParams, SDKConfigParams } from './ConfigParams'; import { makeLogger } from './makeLogger'; import TestHook from './TestHook'; +interface ListenerEntry { + eventName: string; + handler: (...args: any[]) => void; +} + export const badCommandError = new Error('unsupported command'); export const malformedCommand = new Error('command was malformed'); @@ -45,6 +50,30 @@ function makeSdkConfig(options: SDKConfigParams, tag: string) { cf.streamInitialReconnectDelay = maybeTime(options.streaming.initialRetryDelayMs); } + if (options.dataSystem) { + if (options.dataSystem.initializers) { + options.dataSystem.initializers.forEach((initializer) => { + if (initializer.polling?.baseUri) { + cf.baseUri = initializer.polling.baseUri; + } + }); + } + if (options.dataSystem.synchronizers) { + options.dataSystem.synchronizers.forEach((synchronizer) => { + if (synchronizer.streaming?.baseUri) { + cf.streamUri = synchronizer.streaming.baseUri; + cf.streaming = true; + cf.streamInitialReconnectDelay = maybeTime(synchronizer.streaming.initialRetryDelayMs); + if (!cf.baseUri) { + cf.baseUri = synchronizer.streaming.baseUri; + } + } else if (synchronizer.polling?.baseUri) { + cf.baseUri = synchronizer.polling.baseUri; + } + }); + } + } + if (options.events) { if (options.events.baseUri) { cf.eventsUri = options.events.baseUri; @@ -81,12 +110,18 @@ function makeDefaultInitialContext() { } export class ClientEntity { + private readonly _listeners = new Map(); + constructor( private readonly _client: LDClient, private readonly _logger: LDLogger, ) {} close() { + this._listeners.forEach((entry) => { + this._client.off(entry.eventName, entry.handler); + }); + this._listeners.clear(); this._client.close(); this._logger.info('Test ended'); } @@ -186,6 +221,79 @@ export class ClientEntity { this._client.flush(); return undefined; + case CommandType.RegisterFlagChangeListener: { + const p = params.registerFlagChangeListener; + if (!p) { + throw malformedCommand; + } + const eventName = p.flagKey ? `change:${p.flagKey}` : 'change'; + + const handler = (...args: any[]) => { + if (!p.flagKey && args.length >= 2) { + const flagKeys = args[1] as string[]; + flagKeys.forEach((key) => { + fetch(p.callbackUri, { + method: 'POST', + body: JSON.stringify({ listenerId: p.listenerId, flagKey: key }), + }).catch(() => {}); + }); + return; + } + fetch(p.callbackUri, { + method: 'POST', + body: JSON.stringify({ listenerId: p.listenerId, flagKey: p.flagKey }), + }).catch(() => {}); + }; + + this._listeners.set(p.listenerId, { eventName, handler }); + this._client.on(eventName, handler); + return undefined; + } + + case CommandType.RegisterFlagValueChangeListener: { + const p = params.registerFlagValueChangeListener; + if (!p) { + throw malformedCommand; + } + const eventName = `change:${p.flagKey}`; + + let oldValue = this._client.variation(p.flagKey, p.defaultValue); + + const handler = () => { + const newValue = this._client.variation(p.flagKey, p.defaultValue); + if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) { + const previousValue = oldValue; + oldValue = newValue; + fetch(p.callbackUri, { + method: 'POST', + body: JSON.stringify({ + listenerId: p.listenerId, + flagKey: p.flagKey, + oldValue: previousValue, + newValue, + }), + }).catch(() => {}); + } + }; + + this._listeners.set(p.listenerId, { eventName, handler }); + this._client.on(eventName, handler); + return undefined; + } + + case CommandType.UnregisterListener: { + const p = params.unregisterListener; + if (!p) { + throw malformedCommand; + } + const entry = this._listeners.get(p.listenerId); + if (entry) { + this._client.off(entry.eventName, entry.handler); + this._listeners.delete(p.listenerId); + } + return undefined; + } + default: throw badCommandError; } diff --git a/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts b/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts index 11251d31ed..9fb4cb2329 100644 --- a/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts +++ b/packages/sdk/browser/contract-tests/entity/src/CommandParams.ts @@ -11,6 +11,9 @@ export enum CommandType { ContextConvert = 'contextConvert', ContextComparison = 'contextComparison', SecureModeHash = 'secureModeHash', + RegisterFlagChangeListener = 'registerFlagChangeListener', + RegisterFlagValueChangeListener = 'registerFlagValueChangeListener', + UnregisterListener = 'unregisterListener', } export enum ValueType { @@ -31,6 +34,9 @@ export interface CommandParams { contextConvert?: ContextConvertParams; contextComparison?: ContextComparisonPairParams; secureModeHash?: SecureModeHashParams; + registerFlagChangeListener?: RegisterFlagChangeListenerParams; + registerFlagValueChangeListener?: RegisterFlagValueChangeListenerParams; + unregisterListener?: UnregisterListenerParams; } export interface EvaluateFlagParams { @@ -137,6 +143,23 @@ export interface SecureModeHashResponse { result: string; } +export interface RegisterFlagChangeListenerParams { + listenerId: string; + flagKey: string; + callbackUri: string; +} + +export interface RegisterFlagValueChangeListenerParams { + listenerId: string; + flagKey: string; + defaultValue: unknown; + callbackUri: string; +} + +export interface UnregisterListenerParams { + listenerId: string; +} + export enum HookStage { BeforeEvaluation = 'beforeEvaluation', AfterEvaluation = 'afterEvaluation', diff --git a/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts b/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts index 520170e82c..9652baecf0 100644 --- a/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts +++ b/packages/sdk/browser/contract-tests/entity/src/ConfigParams.ts @@ -18,6 +18,7 @@ export interface SDKConfigParams { clientSide?: SDKConfigClientSideParams; hooks?: SDKConfigHooksParams; wrapper?: SDKConfigWrapper; + dataSystem?: SDKConfigDataSystemParams; } export interface SDKConfigTLSParams { @@ -87,4 +88,28 @@ export interface SDKConfigWrapper { version: string; } +export interface SDKConfigDataSystemParams { + initializers?: SDKDataSystemInitializerParams[]; + synchronizers?: SDKDataSystemSynchronizerParams[]; +} + +export interface SDKDataSystemInitializerParams { + polling?: SDKDataSourcePollingParams; +} + +export interface SDKDataSystemSynchronizerParams { + streaming?: SDKDataSourceStreamingParams; + polling?: SDKDataSourcePollingParams; +} + +export interface SDKDataSourceStreamingParams { + baseUri?: string; + initialRetryDelayMs?: number; +} + +export interface SDKDataSourcePollingParams { + baseUri?: string; + pollIntervalMs?: number; +} + export type HookStage = 'beforeEvaluation' | 'afterEvaluation'; diff --git a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts index 86ba4cf6ac..a1440613fe 100644 --- a/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts +++ b/packages/sdk/browser/contract-tests/entity/src/TestHarnessWebSocket.ts @@ -44,6 +44,7 @@ export default class TestHarnessWebSocket { 'client-prereq-events', 'client-per-context-summaries', 'track-hooks', + 'flag-change-listeners', ]; break;