From 3f64157b0ec9dd5afce4bb4bbaa0371938b15f0c Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 23 Dec 2025 17:04:41 -0300 Subject: [PATCH 1/4] Implement factory.client and client.getTreatment methods --- splitio_web/lib/splitio_web.dart | 104 +++++++++++++++++- splitio_web/lib/src/js_interop.dart | 6 + splitio_web/test/splitio_web_test.dart | 93 ++++++++++++++-- .../test/utils/js_interop_test_utils.dart | 2 +- 4 files changed, 196 insertions(+), 9 deletions(-) diff --git a/splitio_web/lib/splitio_web.dart b/splitio_web/lib/splitio_web.dart index 78dfbbe..af7f5f7 100644 --- a/splitio_web/lib/splitio_web.dart +++ b/splitio_web/lib/splitio_web.dart @@ -25,6 +25,8 @@ class SplitioWeb extends SplitioPlatform { String? _trafficType; bool _impressionListener = false; + final Map _clients = {}; + @override Future init({ required String apiKey, @@ -107,7 +109,8 @@ class SplitioWeb extends SplitioPlatform { }.toJS; script.onerror = (Event event) { - completer.completeError(Exception('Failed to load Split SDK')); + completer.completeError( + Exception('Failed to load Split SDK, with error: $event')); }.toJS; document.head!.appendChild(script); @@ -311,4 +314,103 @@ class SplitioWeb extends SplitioPlatform { } return matchingKey.toJS; } + + 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.callAsFunction( + null, _buildKey(matchingKey, bucketingKey)) as JS_IBrowserClient; + + _clients[key] = client; + } + + Future _getClient({ + required String matchingKey, + required String? bucketingKey, + }) async { + await getClient(matchingKey: matchingKey, bucketingKey: bucketingKey); + + final key = _buildKeyString(matchingKey, bucketingKey); + + return _clients[key]!; + } + + JSAny? _convertValue(dynamic value, bool attributes) { + if (value is bool) return value.toJS; + if (value is num) return value.toJS; // covers int + double + if (value is String) return value.toJS; + + // properties do not support lists and sets + if (attributes) { + if (value is List) return value.jsify(); + if (value is Set) return value.jsify(); + } + + return null; + } + + JSObject _convertMap(Map dartMap, bool areAttributes) { + final jsMap = JSObject(); + + dartMap.forEach((key, value) { + final jsValue = _convertValue(value, areAttributes); + + if (jsValue != null) { + jsMap.setProperty(key.toJS, jsValue); + } else { + this._factory.settings.log.warn.callAsFunction( + null, + 'Invalid ${areAttributes ? 'attribute' : 'property'} value: $value, for key: $key, will be ignored' + .toJS); + } + }); + + return jsMap; + } + + JSObject _convertEvaluationOptions(EvaluationOptions evaluationOptions) { + final jsEvalOptions = JSObject(); + + if (evaluationOptions.properties.isNotEmpty) { + jsEvalOptions.setProperty( + 'properties'.toJS, _convertMap(evaluationOptions.properties, false)); + } + + return jsEvalOptions; + } + + @override + Future getTreatment({ + required String matchingKey, + required String? bucketingKey, + required String splitName, + Map attributes = const {}, + EvaluationOptions evaluationOptions = const EvaluationOptions.empty(), + }) async { + final client = await _getClient( + matchingKey: matchingKey, + bucketingKey: bucketingKey, + ); + + final result = client.getTreatment.callAsFunction( + null, + splitName.toJS, + _convertMap(attributes, true), + _convertEvaluationOptions(evaluationOptions)) as JSString; + return result.toDart; + } } diff --git a/splitio_web/lib/src/js_interop.dart b/splitio_web/lib/src/js_interop.dart index fd65946..75889c8 100644 --- a/splitio_web/lib/src/js_interop.dart +++ b/splitio_web/lib/src/js_interop.dart @@ -10,8 +10,14 @@ extension type JS_ISettings._(JSObject _) implements JSObject { external JS_Logger log; } +@JS() +extension type JS_IBrowserClient._(JSObject _) implements JSObject { + external JSFunction getTreatment; +} + @JS() extension type JS_IBrowserSDK._(JSObject _) implements JSObject { + external JSFunction client; external JS_ISettings settings; } diff --git a/splitio_web/test/splitio_web_test.dart b/splitio_web/test/splitio_web_test.dart index c575765..d109f73 100644 --- a/splitio_web/test/splitio_web_test.dart +++ b/splitio_web/test/splitio_web_test.dart @@ -16,17 +16,32 @@ extension on web.Window { } void main() { - final List<({String methodName, List methodArguments})> calls = []; + final List<({String methodName, List methodArguments})> calls = []; + + final mockClient = JSObject(); + mockClient['getTreatment'] = + (JSAny? flagName, JSAny? attributes, JSAny? evaluationOptions) { + calls.add(( + methodName: 'getTreatment', + methodArguments: [flagName, attributes, evaluationOptions] + )); + return 'on'.toJS; + }.toJS; final mockLog = JSObject(); mockLog['warn'] = (JSAny? arg1) { calls.add((methodName: 'warn', methodArguments: [arg1])); }.toJS; + final mockSettings = JSObject(); mockSettings['log'] = mockLog; final mockFactory = JSObject(); mockFactory['settings'] = mockSettings; + mockFactory['client'] = (JSAny? splitKey) { + calls.add((methodName: 'client', methodArguments: [splitKey])); + return mockClient; + }.toJS; final mockSplitio = JSObject(); mockSplitio['SplitFactory'] = (JSAny? arg1) { @@ -34,8 +49,72 @@ void main() { return mockFactory; }.toJS; + SplitioWeb _platform = SplitioWeb(); + setUp(() { - (web.window as JSObject).setProperty('splitio'.toJS, mockSplitio); + (web.window as JSObject)['splitio'] = mockSplitio; + + _platform.init( + apiKey: 'apiKey', + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key'); + }); + + group('evaluation', () { + test('getTreatment without attributes', () async { + final result = await _platform.getTreatment( + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + splitName: 'split'); + + expect(result, 'on'); + expect(calls.last.methodName, 'getTreatment'); + expect(calls.last.methodArguments.map(jsAnyToDart), ['split', {}, {}]); + }); + + test('getTreatment with attributes', () async { + final result = await _platform.getTreatment( + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + splitName: 'split', + attributes: { + 'attrBool': true, + 'attrString': 'value', + 'attrInt': 1, + 'attrDouble': 1.1, + 'attrList': ['value1', 100, false], + 'attrSet': {'value3', 100, true}, + 'attrNull': null, // ignored + 'attrInvalid': {'value5': true} // ignored + }); + + expect(result, 'on'); + expect(calls.last.methodName, 'getTreatment'); + expect(calls.last.methodArguments.map(jsAnyToDart), [ + 'split', + { + 'attrBool': true, + 'attrString': 'value', + 'attrInt': 1, + 'attrDouble': 1.1, + 'attrList': ['value1', 100, false], + 'attrSet': ['value3', 100, true] + }, + {} + ]); + + // assert warnings + expect(calls[calls.length - 2].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 2].methodArguments[0]), + equals( + 'Invalid attribute value: {value5: true}, for key: attrInvalid, will be ignored')); + expect(calls[calls.length - 3].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 3].methodArguments[0]), + equals( + 'Invalid attribute value: null, for key: attrNull, will be ignored')); + }); }); group('initialization', () { @@ -47,7 +126,7 @@ void main() { expect(calls.last.methodName, 'SplitFactory'); expect( - jsObjectToMap(calls.last.methodArguments[0]), + jsAnyToDart(calls.last.methodArguments[0]), equals({ 'core': { 'authorizationKey': 'api-key', @@ -66,7 +145,7 @@ void main() { expect(calls.last.methodName, 'SplitFactory'); expect( - jsObjectToMap(calls.last.methodArguments[0]), + jsAnyToDart(calls.last.methodArguments[0]), equals({ 'core': { 'authorizationKey': 'api-key', @@ -89,7 +168,7 @@ void main() { expect(calls.last.methodName, 'SplitFactory'); expect( - jsObjectToMap(calls.last.methodArguments[0]), + jsAnyToDart(calls.last.methodArguments[0]), equals({ 'core': { 'authorizationKey': 'api-key', @@ -152,7 +231,7 @@ void main() { expect(calls[calls.length - 5].methodName, 'SplitFactory'); expect( - jsObjectToMap(calls[calls.length - 5].methodArguments[0]), + jsAnyToDart(calls[calls.length - 5].methodArguments[0]), equals({ 'core': { 'authorizationKey': 'api-key', @@ -241,7 +320,7 @@ void main() { expect(calls.last.methodName, 'SplitFactory'); expect( - jsObjectToMap(calls.last.methodArguments[0]), + jsAnyToDart(calls.last.methodArguments[0]), equals({ 'core': { 'authorizationKey': 'api-key', diff --git a/splitio_web/test/utils/js_interop_test_utils.dart b/splitio_web/test/utils/js_interop_test_utils.dart index 153e627..bbe78d8 100644 --- a/splitio_web/test/utils/js_interop_test_utils.dart +++ b/splitio_web/test/utils/js_interop_test_utils.dart @@ -25,7 +25,7 @@ dynamic jsAnyToDart(JSAny? value) { } else if (value is JSString) { return value.toDart; } else if (value is JSNumber) { - return value.toDartInt; + return value.toDartDouble; } else if (value is JSBoolean) { return value.toDart; } else { From 3d04068a90d18f1776627fbc310e502d0bb797fc Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 23 Dec 2025 18:04:21 -0300 Subject: [PATCH 2/4] Add test with evaluation options --- splitio_web/test/splitio_web_test.dart | 62 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/splitio_web/test/splitio_web_test.dart b/splitio_web/test/splitio_web_test.dart index d109f73..97b9d6f 100644 --- a/splitio_web/test/splitio_web_test.dart +++ b/splitio_web/test/splitio_web_test.dart @@ -6,6 +6,7 @@ import 'package:splitio_web/splitio_web.dart'; import 'package:splitio_web/src/js_interop.dart'; import 'package:splitio_platform_interface/split_certificate_pinning_configuration.dart'; import 'package:splitio_platform_interface/split_configuration.dart'; +import 'package:splitio_platform_interface/split_evaluation_options.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'; @@ -84,8 +85,8 @@ void main() { 'attrDouble': 1.1, 'attrList': ['value1', 100, false], 'attrSet': {'value3', 100, true}, - 'attrNull': null, // ignored - 'attrInvalid': {'value5': true} // ignored + 'attrNull': null, // not valid attribute value + 'attrMap': {'value5': true} // not valid attribute value }); expect(result, 'on'); @@ -108,13 +109,68 @@ void main() { expect( jsAnyToDart(calls[calls.length - 2].methodArguments[0]), equals( - 'Invalid attribute value: {value5: true}, for key: attrInvalid, will be ignored')); + 'Invalid attribute value: {value5: true}, for key: attrMap, will be ignored')); expect(calls[calls.length - 3].methodName, 'warn'); expect( jsAnyToDart(calls[calls.length - 3].methodArguments[0]), equals( 'Invalid attribute value: null, for key: attrNull, will be ignored')); }); + + test('getTreatment with evaluation properties', () async { + final result = await _platform.getTreatment( + matchingKey: 'matching-key', + bucketingKey: 'bucketing-key', + splitName: 'split', + evaluationOptions: EvaluationOptions({ + 'propBool': true, + 'propString': 'value', + 'propInt': 1, + 'propDouble': 1.1, + 'propList': ['value1', 100, false], // not valid property value + 'propSet': {'value3', 100, true}, // not valid property value + 'propNull': null, // not valid property value + 'propMap': {'value5': true} // not valid property value + })); + + expect(result, 'on'); + expect(calls.last.methodName, 'getTreatment'); + expect(calls.last.methodArguments.map(jsAnyToDart), [ + 'split', + {}, + { + 'properties': { + 'propBool': true, + 'propString': 'value', + 'propInt': 1, + 'propDouble': 1.1, + } + } + ]); + + // assert warnings + expect(calls[calls.length - 2].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 2].methodArguments[0]), + equals( + 'Invalid property value: {value5: true}, for key: propMap, will be ignored')); + expect(calls[calls.length - 3].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 3].methodArguments[0]), + equals( + 'Invalid property value: null, for key: propNull, will be ignored')); + expect(calls[calls.length - 4].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 4].methodArguments[0]), + equals( + 'Invalid property value: {value3, 100, true}, for key: propSet, will be ignored')); + expect(calls[calls.length - 5].methodName, 'warn'); + expect( + jsAnyToDart(calls[calls.length - 5].methodArguments[0]), + equals( + 'Invalid property value: [value1, 100, false], for key: propList, will be ignored')); + }); + }); group('initialization', () { From 029e9f181c1f7ac836a19db52be3e0a23c1f9c7f Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 26 Dec 2025 11:09:41 -0300 Subject: [PATCH 3/4] Rename parameter from `areAttributes` to `isAttributes` for consistency --- splitio_web/lib/splitio_web.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/splitio_web/lib/splitio_web.dart b/splitio_web/lib/splitio_web.dart index af7f5f7..b7bcdf2 100644 --- a/splitio_web/lib/splitio_web.dart +++ b/splitio_web/lib/splitio_web.dart @@ -349,13 +349,13 @@ class SplitioWeb extends SplitioPlatform { return _clients[key]!; } - JSAny? _convertValue(dynamic value, bool attributes) { + JSAny? _convertValue(dynamic value, bool isAttributes) { if (value is bool) return value.toJS; if (value is num) return value.toJS; // covers int + double if (value is String) return value.toJS; // properties do not support lists and sets - if (attributes) { + if (isAttributes) { if (value is List) return value.jsify(); if (value is Set) return value.jsify(); } @@ -363,18 +363,18 @@ class SplitioWeb extends SplitioPlatform { return null; } - JSObject _convertMap(Map dartMap, bool areAttributes) { + JSObject _convertMap(Map dartMap, bool isAttributes) { final jsMap = JSObject(); dartMap.forEach((key, value) { - final jsValue = _convertValue(value, areAttributes); + final jsValue = _convertValue(value, isAttributes); if (jsValue != null) { jsMap.setProperty(key.toJS, jsValue); } else { this._factory.settings.log.warn.callAsFunction( null, - 'Invalid ${areAttributes ? 'attribute' : 'property'} value: $value, for key: $key, will be ignored' + 'Invalid ${isAttributes ? 'attribute' : 'property'} value: $value, for key: $key, will be ignored' .toJS); } }); From c4206590eb717461ea35b5b46a7e882ae4a1fc55 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 26 Dec 2025 13:19:41 -0300 Subject: [PATCH 4/4] Add getClient method tests --- splitio_web/test/splitio_web_test.dart | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/splitio_web/test/splitio_web_test.dart b/splitio_web/test/splitio_web_test.dart index 1e4c4eb..60a124f 100644 --- a/splitio_web/test/splitio_web_test.dart +++ b/splitio_web/test/splitio_web_test.dart @@ -61,7 +61,7 @@ void main() { }); group('evaluation', () { - test('getTreatment without attributes', () async { + test('getTreatment', () async { final result = await _platform.getTreatment( matchingKey: 'matching-key', bucketingKey: 'bucketing-key', @@ -401,4 +401,33 @@ void main() { })); }); }); + + group('client', () { + test('get client with no keys', () async { + await _platform.getClient( + matchingKey: 'matching-key', bucketingKey: null); + + expect(calls.last.methodName, 'client'); + expect(calls.last.methodArguments.map(jsAnyToDart), ['matching-key']); + }); + + test('get client with new matching key', () async { + await _platform.getClient( + matchingKey: 'new-matching-key', bucketingKey: null); + + expect(calls.last.methodName, 'client'); + expect(calls.last.methodArguments.map(jsAnyToDart), ['new-matching-key']); + }); + + test('get client with new matching key and bucketing key', () async { + await _platform.getClient( + matchingKey: 'new-matching-key', bucketingKey: 'bucketing-key'); + + expect(calls.last.methodName, 'client'); + expect(calls.last.methodArguments.map(jsAnyToDart), [ + {'matchingKey': 'new-matching-key', 'bucketingKey': 'bucketing-key'} + ]); + }); + }); + }