diff --git a/splitio/lib/splitio.dart b/splitio/lib/splitio.dart index 948c0b7..82e4bfa 100644 --- a/splitio/lib/splitio.dart +++ b/splitio/lib/splitio.dart @@ -11,6 +11,7 @@ export 'package:splitio_platform_interface/split_sync_config.dart'; export 'package:splitio_platform_interface/split_view.dart'; export 'package:splitio_platform_interface/split_certificate_pinning_configuration.dart'; export 'package:splitio_platform_interface/split_evaluation_options.dart'; +export 'package:splitio_platform_interface/split_rollout_cache_configuration.dart'; typedef ClientReadinessCallback = void Function(SplitClient splitClient); diff --git a/splitio_web/lib/splitio_web.dart b/splitio_web/lib/splitio_web.dart index b8f9242..a7e3119 100644 --- a/splitio_web/lib/splitio_web.dart +++ b/splitio_web/lib/splitio_web.dart @@ -164,20 +164,33 @@ class SplitioWeb extends SplitioPlatform { if (configuration.configurationMap.containsKey('eventsEndpoint')) urls.events = (configuration.configurationMap['eventsEndpoint'] as String).toJS; - if (configuration.configurationMap.containsKey('authServiceEndpoint')) - urls.auth = - (configuration.configurationMap['authServiceEndpoint'] as String) - .toJS; + + // Convert urls for consistency between JS SDK and Android/iOS SDK + if (configuration.configurationMap.containsKey('authServiceEndpoint')) { + final auth = + configuration.configurationMap['authServiceEndpoint'] as String; + final jsAuth = + auth.endsWith('/v2') ? auth.substring(0, auth.length - 3) : auth; + urls.auth = jsAuth.toJS; + } if (configuration.configurationMap - .containsKey('streamingServiceEndpoint')) - urls.streaming = (configuration - .configurationMap['streamingServiceEndpoint'] as String) - .toJS; + .containsKey('streamingServiceEndpoint')) { + final streaming = configuration + .configurationMap['streamingServiceEndpoint'] as String; + final jsStreaming = streaming.endsWith('/sse') + ? streaming.substring(0, streaming.length - 4) + : streaming; + urls.streaming = jsStreaming.toJS; + } if (configuration.configurationMap - .containsKey('telemetryServiceEndpoint')) - urls.telemetry = (configuration - .configurationMap['telemetryServiceEndpoint'] as String) - .toJS; + .containsKey('telemetryServiceEndpoint')) { + final telemetry = configuration + .configurationMap['telemetryServiceEndpoint'] as String; + final jsTelemetry = telemetry.endsWith('/v1') + ? telemetry.substring(0, telemetry.length - 3) + : telemetry; + urls.telemetry = jsTelemetry.toJS; + } config.urls = urls; final sync = JSObject() as JS_ConfigurationSync; @@ -301,37 +314,24 @@ class SplitioWeb extends SplitioPlatform { return config; } - static String _buildKeyString(String matchingKey, String? bucketingKey) { - return bucketingKey == null ? matchingKey : '${matchingKey}_$bucketingKey'; - } - @override Future getClient({ required String matchingKey, required String? bucketingKey, }) async { - await this._initFuture; - - final key = _buildKeyString(matchingKey, bucketingKey); - - if (_clients.containsKey(key)) { - return; - } - - final client = this._factory.client(buildJsKey(matchingKey, bucketingKey)); - - _clients[key] = client; + await _getClient(matchingKey: matchingKey, bucketingKey: bucketingKey); } Future _getClient({ required String matchingKey, required String? bucketingKey, }) async { - await getClient(matchingKey: matchingKey, bucketingKey: bucketingKey); + await this._initFuture; - final key = _buildKeyString(matchingKey, bucketingKey); + final key = buildKeyString(matchingKey, bucketingKey); - return _clients[key]!; + return (_clients[key] ??= + _factory.client(buildJsKey(matchingKey, bucketingKey))); } Future _getManager() async { @@ -814,7 +814,7 @@ class SplitioWeb extends SplitioPlatform { @override Stream? onUpdated( {required String matchingKey, required String? bucketingKey}) { - final client = _clients[_buildKeyString(matchingKey, bucketingKey)]; + final client = _clients[buildKeyString(matchingKey, bucketingKey)]; if (client == null) { return null; diff --git a/splitio_web/lib/src/js_interop.dart b/splitio_web/lib/src/js_interop.dart index 9d7847c..ac73ebc 100644 --- a/splitio_web/lib/src/js_interop.dart +++ b/splitio_web/lib/src/js_interop.dart @@ -330,3 +330,19 @@ JSAny buildJsKey(String matchingKey, String? bucketingKey) { } return matchingKey.toJS; } + +({String matchingKey, String? bucketingKey}) buildDartKey(JSAny splitKey) { + return splitKey is JSString + ? (matchingKey: splitKey.toDart, bucketingKey: null) + : ( + matchingKey: + (reflectGet(splitKey as JSObject, 'matchingKey'.toJS) as JSString) + .toDart, + bucketingKey: + (reflectGet(splitKey, 'bucketingKey'.toJS) as JSString).toDart, + ); +} + +String buildKeyString(String matchingKey, String? bucketingKey) { + return bucketingKey == null ? matchingKey : '${matchingKey}_$bucketingKey'; +} diff --git a/splitio_web/test/splitio_web_test.dart b/splitio_web/test/splitio_web_test.dart index c48e68e..6dcb12a 100644 --- a/splitio_web/test/splitio_web_test.dart +++ b/splitio_web/test/splitio_web_test.dart @@ -43,6 +43,30 @@ void main() { mock.calls.last.methodArguments.map(jsAnyToDart), ['split', {}, {}]); }); + test('getTreatment in multiple clients', () async { + final result = await _platform.getTreatment( + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + splitName: 'split'); + + final result2 = await _platform.getTreatment( + matchingKey: 'matching-key-getTreatment', + bucketingKey: null, + splitName: 'split-getTreatment'); + + expect(result, 'on'); + expect(result2, 'on'); + expect(mock.calls[mock.calls.length - 3].methodName, 'getTreatment'); + expect(mock.calls[mock.calls.length - 3].methodArguments.map(jsAnyToDart), + ['split', {}, {}]); + expect(mock.calls[mock.calls.length - 2].methodName, 'client'); + expect(mock.calls[mock.calls.length - 2].methodArguments.map(jsAnyToDart), + ['matching-key-getTreatment']); + expect(mock.calls.last.methodName, 'getTreatment'); + expect(mock.calls.last.methodArguments.map(jsAnyToDart), + ['split-getTreatment', {}, {}]); + }); + test('getTreatment with attributes', () async { final result = await _platform.getTreatment( matchingKey: 'matching-key', @@ -87,7 +111,7 @@ void main() { 'Invalid attribute value: null, for key: attrNull, will be ignored')); }); - test('getTreatment with evaluation properties', () async { + test('getTreatment with evaluation options', () async { final result = await _platform.getTreatment( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -141,7 +165,7 @@ void main() { 'Invalid property value: [value1, 100, false], for key: propList, will be ignored')); }); - test('getTreatments without attributes', () async { + test('getTreatments', () async { final result = await _platform.getTreatments( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -156,51 +180,58 @@ void main() { ]); }); - test('getTreatments with attributes and evaluation properties', () async { + test('getTreatments with attributes and evaluation options', () async { final result = await _platform.getTreatments( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', splitNames: ['split1', 'split2'], - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, {'split1': 'on', 'split2': 'on'}); expect(mock.calls.last.methodName, 'getTreatments'); expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ ['split1', 'split2'], {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); - test('getTreatmentWithConfig with attributes', () async { + test('getTreatmentWithConfig', () async { final result = await _platform.getTreatmentWithConfig( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', - splitName: 'split1', - attributes: {'attr1': true}); + splitName: 'split1'); expect(result.toString(), SplitResult('on', 'some-config').toString()); expect(mock.calls.last.methodName, 'getTreatmentWithConfig'); - expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ - 'split1', - {'attr1': true}, - {} - ]); + expect( + mock.calls.last.methodArguments.map(jsAnyToDart), ['split1', {}, {}]); }); - test('getTreatmentWithConfig without attributes', () async { + test('getTreatmentWithConfig with attributes and evaluation options', + () async { final result = await _platform.getTreatmentWithConfig( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', - splitName: 'split1'); + splitName: 'split1', + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result.toString(), SplitResult('on', 'some-config').toString()); expect(mock.calls.last.methodName, 'getTreatmentWithConfig'); - expect( - mock.calls.last.methodArguments.map(jsAnyToDart), ['split1', {}, {}]); + expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ + 'split1', + {'attr1': true}, + { + 'properties': {'prop1': true} + } + ]); }); - test('getTreatmentsWithConfig without attributes', () async { + test('getTreatmentsWithConfig', () async { final result = await _platform.getTreatmentsWithConfig( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -221,12 +252,14 @@ void main() { ]); }); - test('getTreatmentsWithConfig with attributes', () async { + test('getTreatmentsWithConfig with attributes and evaluation options', + () async { final result = await _platform.getTreatmentsWithConfig( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', splitNames: ['split1', 'split2'], - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, predicate>((result) { return result.length == 2 && @@ -239,11 +272,13 @@ void main() { expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ ['split1', 'split2'], {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); - test('getTreatmentsByFlagSet without attributes', () async { + test('getTreatmentsByFlagSet', () async { final result = await _platform.getTreatmentsByFlagSet( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -255,23 +290,27 @@ void main() { mock.calls.last.methodArguments.map(jsAnyToDart), ['set_1', {}, {}]); }); - test('getTreatmentsByFlagSet with attributes', () async { + test('getTreatmentsByFlagSet with attributes and evaluation options', + () async { final result = await _platform.getTreatmentsByFlagSet( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', flagSet: 'set_1', - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, {'split1': 'on', 'split2': 'on'}); expect(mock.calls.last.methodName, 'getTreatmentsByFlagSet'); expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ 'set_1', {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); - test('getTreatmentsByFlagSets without attributes', () async { + test('getTreatmentsByFlagSets', () async { final result = await _platform.getTreatmentsByFlagSets( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -286,23 +325,27 @@ void main() { ]); }); - test('getTreatmentsByFlagSets with attributes', () async { + test('getTreatmentsByFlagSets with attributes and evaluation options', + () async { final result = await _platform.getTreatmentsByFlagSets( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', flagSets: ['set_1', 'set_2'], - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, {'split1': 'on', 'split2': 'on'}); expect(mock.calls.last.methodName, 'getTreatmentsByFlagSets'); expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ ['set_1', 'set_2'], {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); - test('getTreatmentsWithConfigByFlagSet without attributes', () async { + test('getTreatmentsWithConfigByFlagSet', () async { final result = await _platform.getTreatmentsWithConfigByFlagSet( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -320,12 +363,15 @@ void main() { mock.calls.last.methodArguments.map(jsAnyToDart), ['set_1', {}, {}]); }); - test('getTreatmentsWithConfigByFlagSet with attributes', () async { + test( + 'getTreatmentsWithConfigByFlagSet with attributes and evaluation options', + () async { final result = await _platform.getTreatmentsWithConfigByFlagSet( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', flagSet: 'set_1', - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, predicate>((result) { return result.length == 2 && @@ -338,11 +384,13 @@ void main() { expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ 'set_1', {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); - test('getTreatmentsWithConfigByFlagSets without attributes', () async { + test('getTreatmentsWithConfigByFlagSets', () async { final result = await _platform.getTreatmentsWithConfigByFlagSets( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -363,12 +411,15 @@ void main() { ]); }); - test('getTreatmentsWithConfigByFlagSets with attributes', () async { + test( + 'getTreatmentsWithConfigByFlagSets with attributes and evaluation options', + () async { final result = await _platform.getTreatmentsWithConfigByFlagSets( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', flagSets: ['set_1', 'set_2'], - attributes: {'attr1': true}); + attributes: {'attr1': true}, + evaluationOptions: EvaluationOptions({'prop1': true})); expect(result, predicate>((result) { return result.length == 2 && @@ -381,7 +432,9 @@ void main() { expect(mock.calls.last.methodArguments.map(jsAnyToDart), [ ['set_1', 'set_2'], {'attr1': true}, - {} + { + 'properties': {'prop1': true} + } ]); }); }); @@ -647,11 +700,12 @@ void main() { streamingEnabled: false, persistentAttributesEnabled: true, // unsupported in Web impressionListener: true, - sdkEndpoint: 'sdk-endpoint', - eventsEndpoint: 'events-endpoint', - authServiceEndpoint: 'auth-service-endpoint', - streamingServiceEndpoint: 'streaming-service-endpoint', - telemetryServiceEndpoint: 'telemetry-service-endpoint', + sdkEndpoint: 'https://sdk.domain/api', + eventsEndpoint: 'https://events.domain/api', + authServiceEndpoint: 'https://auth.domain/api/v2', + streamingServiceEndpoint: 'https://streaming.domain/sse', + telemetryServiceEndpoint: + 'https://telemetry.domain/api/v1', syncConfig: SyncConfig( names: ['flag_1', 'flag_2'], prefixes: ['prefix_1']), impressionsMode: ImpressionsMode.none, @@ -693,11 +747,11 @@ void main() { 'eventsPushRate': 7, }, 'urls': { - 'sdk': 'sdk-endpoint', - 'events': 'events-endpoint', - 'auth': 'auth-service-endpoint', - 'streaming': 'streaming-service-endpoint', - 'telemetry': 'telemetry-service-endpoint', + 'sdk': 'https://sdk.domain/api', + 'events': 'https://events.domain/api', + 'auth': 'https://auth.domain/api', + 'streaming': 'https://streaming.domain', + 'telemetry': 'https://telemetry.domain/api', }, 'sync': { 'impressionsMode': 'NONE', @@ -917,18 +971,24 @@ void main() { expect(onReadyFromCache, completion(equals(true))); }); - test('onTimeout', () { + test('onTimeout (in multiple clients)', () async { Future? onTimeout = _platform .onTimeout(matchingKey: 'matching-key', bucketingKey: 'bucketing-key') ?.then((value) => true); - // Emit SDK_READY_TIMED_OUT event + Future? onTimeoutClient2 = _platform + .onTimeout(matchingKey: 'matching-key-2', bucketingKey: null) + ?.then((value) => false); + + // Emit SDK_READY_TIMED_OUT event on the first client final mockClient = mock.mockFactory.client(buildJsKey('matching-key', 'bucketing-key')); mockClient.emit .callAsFunction(null, mockClient.Event.SDK_READY_TIMED_OUT); - expect(onTimeout, completion(equals(true))); + // Assert that onTimeout is completed for the first client only + await expectLater(onTimeout, completion(isTrue)); + await expectLater(onTimeoutClient2, doesNotComplete); }); test('onUpdated', () async { diff --git a/splitio_web/test/utils/js_interop_test_utils.dart b/splitio_web/test/utils/js_interop_test_utils.dart index 5fe842c..9854815 100644 --- a/splitio_web/test/utils/js_interop_test_utils.dart +++ b/splitio_web/test/utils/js_interop_test_utils.dart @@ -15,13 +15,16 @@ class SplitioMock { final List<({String methodName, List methodArguments})> calls = []; final JS_IBrowserSDK mockFactory = JSObject() as JS_IBrowserSDK; + final _mockEvents = { + 'SDK_READY': 'init::ready', + 'SDK_READY_FROM_CACHE': 'init::cache-ready', + 'SDK_READY_TIMED_OUT': 'init::timeout', + 'SDK_UPDATE': 'state::update' + }.jsify() as JS_EventConsts; + final _mockClients = {}; + + JS_Configuration? _config; JSString _userConsent = 'UNKNOWN'.toJS; - JS_ReadinessStatus _readinessStatus = { - 'isReady': false, - 'isReadyFromCache': false, - 'hasTimedout': false, - }.jsify() as JS_ReadinessStatus; - Map> _eventListeners = {}; JSObject _createSplitViewJSObject(JSString splitName) { return { @@ -74,15 +77,79 @@ class SplitioMock { return ['split1'.toJS, 'split2'.toJS].jsify(); }.toJS); - final mockEvents = { - 'SDK_READY': 'init::ready', - 'SDK_READY_FROM_CACHE': 'init::cache-ready', - 'SDK_READY_TIMED_OUT': 'init::timeout', - 'SDK_UPDATE': 'state::update' - }.jsify() as JS_EventConsts; + final mockLog = JSObject() as JS_Logger; + reflectSet( + mockLog, + 'warn'.toJS, + (JSAny? arg1) { + calls.add((methodName: 'warn', methodArguments: [arg1])); + }.toJS); + + final mockUserConsent = JSObject() as JS_IUserConsentAPI; + reflectSet( + mockUserConsent, + 'setStatus'.toJS, + (JSBoolean arg1) { + _userConsent = arg1.toDart ? 'GRANTED'.toJS : 'DECLINED'.toJS; + calls.add((methodName: 'setStatus', methodArguments: [arg1])); + return true.toJS; + }.toJS); + reflectSet( + mockUserConsent, + 'getStatus'.toJS, + () { + calls.add((methodName: 'getStatus', methodArguments: [])); + return _userConsent; + }.toJS); + reflectSet( + mockFactory, + 'client'.toJS, + (JSAny? splitKey) { + calls.add((methodName: 'client', methodArguments: [splitKey])); + + final dartKey = buildDartKey(splitKey ?? _config!.core.key); + final stringKey = + buildKeyString(dartKey.matchingKey, dartKey.bucketingKey); + _mockClients[stringKey] ??= _buildMockClient(); + return _mockClients[stringKey]; + }.toJS); + reflectSet( + mockFactory, + 'manager'.toJS, + () { + calls.add((methodName: 'manager', methodArguments: [])); + return mockManager; + }.toJS); + mockFactory.UserConsent = mockUserConsent; + + reflectSet( + splitio, + 'SplitFactory'.toJS, + (JS_Configuration config) { + calls.add((methodName: 'SplitFactory', methodArguments: [config])); + + final mockSettings = + _objectAssign(JSObject(), config) as JS_ISettings; + mockSettings.log = mockLog; + mockFactory.settings = mockSettings; + + _config = config; + + return mockFactory; + }.toJS); + } + + JS_IBrowserClient _buildMockClient() { + final JS_ReadinessStatus _readinessStatus = { + 'isReady': false, + 'isReadyFromCache': false, + 'hasTimedout': false, + }.jsify() as JS_ReadinessStatus; + final Map> _eventListeners = {}; final mockClient = JSObject() as JS_IBrowserClient; - mockClient.Event = mockEvents; + + mockClient.Event = _mockEvents; mockClient.on = (JSString event, JSFunction listener) { calls.add((methodName: 'on', methodArguments: [event, listener])); _eventListeners[event] ??= Set(); @@ -98,11 +165,11 @@ class SplitioMock { _eventListeners[event]?.forEach((listener) { listener.callAsFunction(null, event); }); - if (event == mockEvents.SDK_READY) { + if (event == _mockEvents.SDK_READY) { _readinessStatus.isReady = true.toJS; - } else if (event == mockEvents.SDK_READY_FROM_CACHE) { + } else if (event == _mockEvents.SDK_READY_FROM_CACHE) { _readinessStatus.isReadyFromCache = true.toJS; - } else if (event == mockEvents.SDK_READY_TIMED_OUT) { + } else if (event == _mockEvents.SDK_READY_TIMED_OUT) { _readinessStatus.hasTimedout = true.toJS; } }.toJS; @@ -319,59 +386,6 @@ class SplitioMock { return _promiseResolve(); }.toJS); - final mockLog = JSObject() as JS_Logger; - reflectSet( - mockLog, - 'warn'.toJS, - (JSAny? arg1) { - calls.add((methodName: 'warn', methodArguments: [arg1])); - }.toJS); - - final mockUserConsent = JSObject() as JS_IUserConsentAPI; - reflectSet( - mockUserConsent, - 'setStatus'.toJS, - (JSBoolean arg1) { - _userConsent = arg1.toDart ? 'GRANTED'.toJS : 'DECLINED'.toJS; - calls.add((methodName: 'setStatus', methodArguments: [arg1])); - return true.toJS; - }.toJS); - reflectSet( - mockUserConsent, - 'getStatus'.toJS, - () { - calls.add((methodName: 'getStatus', methodArguments: [])); - return _userConsent; - }.toJS); - - reflectSet( - mockFactory, - 'client'.toJS, - (JSAny? splitKey) { - calls.add((methodName: 'client', methodArguments: [splitKey])); - return mockClient; - }.toJS); - reflectSet( - mockFactory, - 'manager'.toJS, - () { - calls.add((methodName: 'manager', methodArguments: [])); - return mockManager; - }.toJS); - mockFactory.UserConsent = mockUserConsent; - - reflectSet( - splitio, - 'SplitFactory'.toJS, - (JSObject config) { - calls.add((methodName: 'SplitFactory', methodArguments: [config])); - - final mockSettings = - _objectAssign(JSObject(), config) as JS_ISettings; - mockSettings.log = mockLog; - mockFactory.settings = mockSettings; - - return mockFactory; - }.toJS); + return mockClient; } }