diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index eb7e486..3a4a238 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,3 +25,5 @@ jobs: run: cd splitio_android/; flutter test - name: Run flutter splitio_ios test run: cd splitio_ios/; flutter test + - name: Run flutter splitio_web test + run: cd splitio_web/; flutter test --platform=chrome diff --git a/splitio_platform_interface/lib/split_configuration.dart b/splitio_platform_interface/lib/split_configuration.dart index ffe9a9d..1d54fdd 100644 --- a/splitio_platform_interface/lib/split_configuration.dart +++ b/splitio_platform_interface/lib/split_configuration.dart @@ -21,7 +21,7 @@ class SplitConfiguration { /// /// [eventFlushInterval] When using .track, how often the events queue is flushed to Split servers. /// - /// [eventsPerPush] Maximum size of the batch to push events. + /// [eventsPerPush] Maximum size of the batch to push events. Not supported in Web. /// /// [trafficType] The default traffic type for events tracked using the track method. If not specified, every track call should specify a traffic type. /// @@ -29,7 +29,7 @@ class SplitConfiguration { /// /// [streamingEnabled] Boolean flag to enable the streaming service as default synchronization mechanism when in foreground. In the event of an issue with streaming, the SDK will fallback to the polling mechanism. If false, the SDK will poll for changes as usual without attempting to use streaming. /// - /// [persistentAttributesEnabled] Enables saving attributes on persistent cache which is loaded as part of the SDK_READY_FROM_CACHE flow. All functions that mutate the stored attributes map affect the persistent cache. + /// [persistentAttributesEnabled] Enables saving attributes on persistent cache which is loaded as part of the SDK_READY_FROM_CACHE flow. All functions that mutate the stored attributes map affect the persistent cache. Not supported in Web. /// /// [impressionListener] Enables impression listener. If true, generated impressions will be streamed in the impressionsStream() method of Splitio. /// @@ -41,13 +41,13 @@ class SplitConfiguration { /// /// [userConsent] User consent status used to control the tracking of events and impressions. Possible values are [UserConsent.granted], [UserConsent.declined], and [UserConsent.unknown]. /// - /// [encryptionEnabled] If set to true, the local database contents is encrypted. Defaults to false. + /// [encryptionEnabled] If set to true, the local database contents is encrypted. Defaults to false. Not supported in Web. /// /// [logLevel] Enables logging according to the level specified. Options are [SplitLogLevel.verbose], [SplitLogLevel.none], [SplitLogLevel.debug], [SplitLogLevel.info], [SplitLogLevel.warning], and [SplitLogLevel.error]. /// /// [readyTimeout] Maximum amount of time in seconds to wait before firing the SDK_READY_TIMED_OUT event. Defaults to 10 seconds. /// - /// [certificatePinningConfiguration] Certificate pinning configuration. Pins need to have the format of a base64 SHA-256 or base64 SHA-1 hashes of the SPKI (ex.: "sha256/7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y="). + /// [certificatePinningConfiguration] Certificate pinning configuration. Pins need to have the format of a base64 SHA-256 or base64 SHA-1 hashes of the SPKI (ex.: "sha256/7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y="). Not supported in Web. SplitConfiguration({ int? featuresRefreshRate, int? segmentsRefreshRate, diff --git a/splitio_web/lib/splitio_web.dart b/splitio_web/lib/splitio_web.dart index cc9bdfb..7a3ad91 100644 --- a/splitio_web/lib/splitio_web.dart +++ b/splitio_web/lib/splitio_web.dart @@ -22,6 +22,8 @@ class SplitioWeb extends SplitioPlatform { Future? _initFuture; late JS_IBrowserSDK _factory; + String? _trafficType; + bool _impressionListener = false; @override Future init({ @@ -31,7 +33,11 @@ class SplitioWeb extends SplitioPlatform { SplitConfiguration? sdkConfiguration, }) async { if (_initFuture == null) { - _initFuture = this._init(apiKey: apiKey, matchingKey: matchingKey, bucketingKey: bucketingKey, sdkConfiguration: sdkConfiguration); + _initFuture = this._init( + apiKey: apiKey, + matchingKey: matchingKey, + bucketingKey: bucketingKey, + sdkConfiguration: sdkConfiguration); } return _initFuture; } @@ -44,10 +50,23 @@ class SplitioWeb extends SplitioPlatform { }) async { await _loadSplitSdk(); - final config = _buildConfig(apiKey, matchingKey, bucketingKey, sdkConfiguration); + final config = + _buildConfig(apiKey, matchingKey, bucketingKey, sdkConfiguration); // Create factory instance - this._factory = window.splitio!.SplitFactory.callAsFunction(null, config) as JS_IBrowserSDK; + this._factory = window.splitio!.SplitFactory.callAsFunction(null, config) + as JS_IBrowserSDK; + + if (sdkConfiguration != null) { + if (sdkConfiguration.configurationMap['trafficType'] is String) { + this._trafficType = sdkConfiguration.configurationMap['trafficType']; + } + + if (sdkConfiguration.configurationMap['impressionListener'] is bool) { + this._impressionListener = + sdkConfiguration.configurationMap['impressionListener']; + } + } return; } @@ -85,17 +104,195 @@ class SplitioWeb extends SplitioPlatform { } // Map SplitConfiguration to JS equivalent object - JSObject _buildConfig(String apiKey, String matchingKey, String? bucketingKey, SplitConfiguration? configuration) { + static JSObject _buildConfig(String apiKey, String matchingKey, + String? bucketingKey, SplitConfiguration? configuration) { + final config = JSObject(); + final core = JSObject(); core.setProperty('authorizationKey'.toJS, apiKey.toJS); - // @TODO: set bucketingKey if provided - core.setProperty('key'.toJS, matchingKey.toJS); - - final config = JSObject(); + core.setProperty('key'.toJS, _buildKey(matchingKey, bucketingKey)); config.setProperty('core'.toJS, core); - // @TODO: complete config + if (configuration != null) { + final scheduler = JSObject(); + if (configuration.configurationMap.containsKey('featuresRefreshRate')) + scheduler.setProperty( + 'featuresRefreshRate'.toJS, + (configuration.configurationMap['featuresRefreshRate'] as int) + .toJS); + if (configuration.configurationMap.containsKey('segmentsRefreshRate')) + scheduler.setProperty( + 'segmentsRefreshRate'.toJS, + (configuration.configurationMap['segmentsRefreshRate'] as int) + .toJS); + if (configuration.configurationMap.containsKey('impressionsRefreshRate')) + scheduler.setProperty( + 'impressionsRefreshRate'.toJS, + (configuration.configurationMap['impressionsRefreshRate'] as int) + .toJS); + if (configuration.configurationMap.containsKey('telemetryRefreshRate')) + scheduler.setProperty( + 'telemetryRefreshRate'.toJS, + (configuration.configurationMap['telemetryRefreshRate'] as int) + .toJS); + if (configuration.configurationMap.containsKey('eventsQueueSize')) + scheduler.setProperty('eventsQueueSize'.toJS, + (configuration.configurationMap['eventsQueueSize'] as int).toJS); + if (configuration.configurationMap.containsKey('impressionsQueueSize')) + scheduler.setProperty( + 'impressionsQueueSize'.toJS, + (configuration.configurationMap['impressionsQueueSize'] as int) + .toJS); + if (configuration.configurationMap.containsKey('eventFlushInterval')) + scheduler.setProperty('eventsPushRate'.toJS, + (configuration.configurationMap['eventFlushInterval'] as int).toJS); + config.setProperty('scheduler'.toJS, scheduler); + + if (configuration.configurationMap.containsKey('streamingEnabled')) + config.setProperty('streamingEnabled'.toJS, + (configuration.configurationMap['streamingEnabled'] as bool).toJS); + + final urls = JSObject(); + if (configuration.configurationMap.containsKey('sdkEndpoint')) + urls.setProperty('sdk'.toJS, + (configuration.configurationMap['sdkEndpoint'] as String).toJS); + if (configuration.configurationMap.containsKey('eventsEndpoint')) + urls.setProperty('events'.toJS, + (configuration.configurationMap['eventsEndpoint'] as String).toJS); + if (configuration.configurationMap.containsKey('authServiceEndpoint')) + urls.setProperty( + 'auth'.toJS, + (configuration.configurationMap['authServiceEndpoint'] as String) + .toJS); + if (configuration.configurationMap + .containsKey('streamingServiceEndpoint')) + urls.setProperty( + 'streaming'.toJS, + (configuration.configurationMap['streamingServiceEndpoint'] + as String) + .toJS); + if (configuration.configurationMap + .containsKey('telemetryServiceEndpoint')) + urls.setProperty( + 'telemetry'.toJS, + (configuration.configurationMap['telemetryServiceEndpoint'] + as String) + .toJS); + config.setProperty('urls'.toJS, urls); + + final sync = JSObject(); + if (configuration.configurationMap['impressionsMode'] != null) { + sync.setProperty( + 'impressionsMode'.toJS, + (configuration.configurationMap['impressionsMode'] as String) + .toUpperCase() + .toJS); + } + + if (configuration.configurationMap['syncEnabled'] != null) { + sync.setProperty('enabled'.toJS, + (configuration.configurationMap['syncEnabled'] as bool).toJS); + } + + if (configuration.configurationMap['syncConfig'] != null) { + final syncConfig = configuration.configurationMap['syncConfig'] + as Map>; + final List> splitFilters = []; + + if (syncConfig['syncConfigNames'] != null && + syncConfig['syncConfigNames']!.isNotEmpty) { + splitFilters + .add({'type': 'byName', 'values': syncConfig['syncConfigNames']}); + } + + if (syncConfig['syncConfigPrefixes'] != null && + syncConfig['syncConfigPrefixes']!.isNotEmpty) { + splitFilters.add( + {'type': 'byPrefix', 'values': syncConfig['syncConfigPrefixes']}); + } + + if (syncConfig['syncConfigFlagSets'] != null && + syncConfig['syncConfigFlagSets']!.isNotEmpty) { + splitFilters.add( + {'type': 'bySet', 'values': syncConfig['syncConfigFlagSets']}); + } + sync.setProperty('splitFilters'.toJS, splitFilters.jsify()); + } + config.setProperty('sync'.toJS, sync); + + if (configuration.configurationMap['userConsent'] != null) { + config.setProperty( + 'userConsent'.toJS, + (configuration.configurationMap['userConsent'] as String) + .toUpperCase() + .toJS); + } + + final logLevel = configuration.configurationMap['logLevel']; + if (logLevel is String) { + final logger = logLevel == SplitLogLevel.verbose.toString() || + logLevel == SplitLogLevel.debug.toString() + ? window.splitio!.DebugLogger?.callAsFunction(null) + : logLevel == SplitLogLevel.info.toString() + ? window.splitio!.InfoLogger?.callAsFunction(null) + : logLevel == SplitLogLevel.warning.toString() + ? window.splitio!.WarnLogger?.callAsFunction(null) + : logLevel == SplitLogLevel.error.toString() + ? window.splitio!.ErrorLogger?.callAsFunction(null) + : null; + if (logger != null) { + config.setProperty('debug'.toJS, logger); // Browser SDK + } else { + config.setProperty( + 'debug'.toJS, logLevel.toUpperCase().toJS); // JS SDK + } + } else if (configuration.configurationMap['enableDebug'] == true) { + config.setProperty( + 'debug'.toJS, window.splitio!.DebugLogger?.callAsFunction(null)); + } + + if (configuration.configurationMap['readyTimeout'] != null) { + final startup = JSObject(); + startup.setProperty('readyTimeout'.toJS, + (configuration.configurationMap['readyTimeout'] as int).toJS); + config.setProperty('startup'.toJS, startup); + } + + final storageOptions = JSObject(); + storageOptions.setProperty('type'.toJS, 'LOCALSTORAGE'.toJS); + if (configuration.configurationMap['rolloutCacheConfiguration'] != null) { + final rolloutCacheConfiguration = + configuration.configurationMap['rolloutCacheConfiguration'] + as Map; + if (rolloutCacheConfiguration['expirationDays'] != null) { + storageOptions.setProperty('expirationDays'.toJS, + (rolloutCacheConfiguration['expirationDays'] as int).toJS); + } + if (rolloutCacheConfiguration['clearOnInit'] != null) { + storageOptions.setProperty('clearOnInit'.toJS, + (rolloutCacheConfiguration['clearOnInit'] as bool).toJS); + } + } + if (window.splitio!.InLocalStorage != null) { + config.setProperty( + 'storage'.toJS, + window.splitio!.InLocalStorage + ?.callAsFunction(null, storageOptions)); // Browser SDK + } else { + config.setProperty('storage'.toJS, storageOptions); // JS SDK + } + } + return config; } + static JSAny _buildKey(String matchingKey, String? bucketingKey) { + if (bucketingKey != null) { + final splitKey = JSObject(); + splitKey.setProperty('matchingKey'.toJS, matchingKey.toJS); + splitKey.setProperty('bucketingKey'.toJS, bucketingKey.toJS); + return splitKey; + } + return matchingKey.toJS; + } } diff --git a/splitio_web/lib/src/js_interop.dart b/splitio_web/lib/src/js_interop.dart index 28192c0..312f50e 100644 --- a/splitio_web/lib/src/js_interop.dart +++ b/splitio_web/lib/src/js_interop.dart @@ -1,9 +1,15 @@ import 'dart:js_interop'; @JS() -extension type JS_IBrowserSDK._(JSObject _) implements JSObject {} +extension type JS_IBrowserSDK._(JSObject _) implements JSObject { +} @JS() extension type JS_BrowserSDKPackage._(JSObject _) implements JSObject { external JSFunction SplitFactory; + external JSFunction? InLocalStorage; + external JSFunction? DebugLogger; + external JSFunction? InfoLogger; + external JSFunction? WarnLogger; + external JSFunction? ErrorLogger; } diff --git a/splitio_web/test/splitio_web_test.dart b/splitio_web/test/splitio_web_test.dart new file mode 100644 index 0000000..5c3dafe --- /dev/null +++ b/splitio_web/test/splitio_web_test.dart @@ -0,0 +1,240 @@ +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'package:web/web.dart' as web; +import 'package:flutter_test/flutter_test.dart'; +import 'package:splitio_web/splitio_web.dart'; +import 'package:splitio_web/src/js_interop.dart'; +import 'package:splitio_platform_interface/split_configuration.dart'; +import 'package:splitio_platform_interface/split_sync_config.dart'; +import 'package:splitio_platform_interface/split_rollout_cache_configuration.dart'; +import 'utils/js_interop_test_utils.dart'; + +extension on web.Window { + @JS() + external JS_BrowserSDKPackage? splitio; +} + +void main() { + String methodName = ''; + dynamic methodArguments; + + setUp(() { + final mockFactory = JSObject(); + + final mockSplitio = JSObject(); + mockSplitio['SplitFactory'] = (JSAny? arg1) { + methodName = 'SplitFactory'; + methodArguments = [arg1]; + return mockFactory; + }.toJS; + + (web.window as JSObject).setProperty('splitio'.toJS, mockSplitio); + }); + + group('initialization', () { + test('init with matching key only', () async { + SplitioWeb _platform = SplitioWeb(); + + await _platform.init( + apiKey: 'api-key', matchingKey: 'matching-key', bucketingKey: null); + + expect(methodName, 'SplitFactory'); + expect( + jsObjectToMap(methodArguments[0]), + equals({ + 'core': { + 'authorizationKey': 'api-key', + 'key': 'matching-key', + } + })); + }); + + test('init with bucketing key', () async { + SplitioWeb _platform = SplitioWeb(); + + await _platform.init( + apiKey: 'api-key', + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key'); + + expect(methodName, 'SplitFactory'); + expect( + jsObjectToMap(methodArguments[0]), + equals({ + 'core': { + 'authorizationKey': 'api-key', + 'key': { + 'matchingKey': 'matching-key', + 'bucketingKey': 'bucketing-key', + }, + } + })); + }); + + test('init with config: empty config', () async { + SplitioWeb _platform = SplitioWeb(); + + await _platform.init( + apiKey: 'api-key', + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + sdkConfiguration: SplitConfiguration()); + + expect(methodName, 'SplitFactory'); + expect( + jsObjectToMap(methodArguments[0]), + equals({ + 'core': { + 'authorizationKey': 'api-key', + 'key': { + 'matchingKey': 'matching-key', + 'bucketingKey': 'bucketing-key', + }, + }, + 'startup': { + 'readyTimeout': 10, + }, + 'scheduler': {}, + 'urls': {}, + 'sync': {}, + 'storage': {'type': 'LOCALSTORAGE'} + })); + }); + + // @TODO validate warning for unsupported config options + // @TODO validate full config with pluggable Browser SDK modules + test('init with config: full config', () async { + SplitioWeb _platform = SplitioWeb(); + + await _platform.init( + apiKey: 'api-key', + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + sdkConfiguration: SplitConfiguration( + featuresRefreshRate: 1, + segmentsRefreshRate: 2, + impressionsRefreshRate: 3, + telemetryRefreshRate: 4, + eventsQueueSize: 5, + impressionsQueueSize: 6, + eventFlushInterval: 7, + // eventsPerPush: 8, // unsupported in Web + trafficType: 'user', + enableDebug: false, // deprecated, logLevel has precedence + 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', + syncConfig: SyncConfig( + names: ['flag_1', 'flag_2'], prefixes: ['prefix_1']), + impressionsMode: ImpressionsMode.none, + syncEnabled: true, + userConsent: UserConsent.granted, + // encryptionEnabled: true, // unsupported in Web + logLevel: SplitLogLevel.info, + readyTimeout: 1, + // certificatePinningConfiguration: + // CertificatePinningConfiguration(), // unsupported in Web + rolloutCacheConfiguration: RolloutCacheConfiguration( + expirationDays: 100, + clearOnInit: true, + ))); + + expect(methodName, 'SplitFactory'); + expect( + jsObjectToMap(methodArguments[0]), + equals({ + 'core': { + 'authorizationKey': 'api-key', + 'key': { + 'matchingKey': 'matching-key', + 'bucketingKey': 'bucketing-key', + }, + }, + 'streamingEnabled': false, + 'startup': { + 'readyTimeout': 1, + }, + 'debug': 'INFO', + 'scheduler': { + 'featuresRefreshRate': 1, + 'segmentsRefreshRate': 2, + 'impressionsRefreshRate': 3, + 'telemetryRefreshRate': 4, + 'eventsQueueSize': 5, + 'impressionsQueueSize': 6, + 'eventsPushRate': 7, + }, + 'urls': { + 'sdk': 'sdk-endpoint', + 'events': 'events-endpoint', + 'auth': 'auth-service-endpoint', + 'streaming': 'streaming-service-endpoint', + 'telemetry': 'telemetry-service-endpoint', + }, + 'sync': { + 'impressionsMode': 'NONE', + 'enabled': true, + 'splitFilters': [ + { + 'type': 'byName', + 'values': ['flag_1', 'flag_2'] + }, + { + 'type': 'byPrefix', + 'values': ['prefix_1'] + } + ] + }, + 'userConsent': 'GRANTED', + 'storage': { + 'type': 'LOCALSTORAGE', + 'expirationDays': 100, + 'clearOnInit': true + } + })); + }); + + test('init with config: SyncConfig.flagSets', () async { + SplitioWeb _platform = SplitioWeb(); + + await _platform.init( + apiKey: 'api-key', + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + sdkConfiguration: SplitConfiguration( + syncConfig: SyncConfig.flagSets(['flag_set_1', 'flag_set_2']))); + + expect(methodName, 'SplitFactory'); + expect( + jsObjectToMap(methodArguments[0]), + equals({ + 'core': { + 'authorizationKey': 'api-key', + 'key': { + 'matchingKey': 'matching-key', + 'bucketingKey': 'bucketing-key', + }, + }, + 'startup': { + 'readyTimeout': 10, + }, + 'scheduler': {}, + 'urls': {}, + 'sync': { + 'splitFilters': [ + { + 'type': 'bySet', + 'values': ['flag_set_1', 'flag_set_2'] + } + ] + }, + 'storage': {'type': 'LOCALSTORAGE'} + })); + }); + }); +} diff --git a/splitio_web/test/utils/js_interop_test_utils.dart b/splitio_web/test/utils/js_interop_test_utils.dart new file mode 100644 index 0000000..153e627 --- /dev/null +++ b/splitio_web/test/utils/js_interop_test_utils.dart @@ -0,0 +1,34 @@ +import 'dart:js_interop'; + +@JS('Object.keys') +external JSArray _objectKeys(JSObject obj); + +@JS('Reflect.get') +external JSAny? _reflectGet(JSObject target, JSAny propertyKey); + +List jsArrayToList(JSArray obj) { + return obj.toDart.map(jsAnyToDart).toList(); +} + +Map jsObjectToMap(JSObject obj) { + return { + for (final jsKey in _objectKeys(obj).toDart) + jsKey.toDart: jsAnyToDart(_reflectGet(obj, jsKey)), + }; +} + +dynamic jsAnyToDart(JSAny? value) { + if (value is JSArray) { + return jsArrayToList(value); + } else if (value is JSObject) { + return jsObjectToMap(value); + } else if (value is JSString) { + return value.toDart; + } else if (value is JSNumber) { + return value.toDartInt; + } else if (value is JSBoolean) { + return value.toDart; + } else { + return value; // JS null and undefined are null in Dart + } +}