diff --git a/packages/optimizely-sdk/lib/index.react_native.js b/packages/optimizely-sdk/lib/index.react_native.js new file mode 100644 index 000000000..23bdbd1a8 --- /dev/null +++ b/packages/optimizely-sdk/lib/index.react_native.js @@ -0,0 +1,121 @@ +/** + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var logging = require('@optimizely/js-sdk-logging'); +var fns = require('./utils/fns'); +var configValidator = require('./utils/config_validator'); +var defaultErrorHandler = require('./plugins/error_handler'); +var defaultEventDispatcher = require('./plugins/event_dispatcher/index.browser'); +var enums = require('./utils/enums'); +var loggerPlugin = require('./plugins/logger/index.react_native'); +var Optimizely = require('./optimizely'); +var eventProcessorConfigValidator = require('./utils/event_processor_config_validator'); + +var logger = logging.getLogger(); +logging.setLogHandler(loggerPlugin.createLogger()); +logging.setLogLevel(logging.LogLevel.INFO); + +var DEFAULT_EVENT_BATCH_SIZE = 10; +var DEFAULT_EVENT_FLUSH_INTERVAL = 1000; // Unit is ms, default is 1s + +/** + * Entry point into the Optimizely Javascript SDK for React Native + */ +module.exports = { + logging: loggerPlugin, + errorHandler: defaultErrorHandler, + eventDispatcher: defaultEventDispatcher, + enums: enums, + + setLogger: logging.setLogHandler, + setLogLevel: logging.setLogLevel, + + /** + * Creates an instance of the Optimizely class + * @param {Object} config + * @param {Object} config.datafile + * @param {Object} config.errorHandler + * @param {Object} config.eventDispatcher + * @param {Object} config.logger + * @param {Object} config.logLevel + * @param {Object} config.userProfileService + * @param {Object} config.eventBatchSize + * @param {Object} config.eventFlushInterval + * @return {Object} the Optimizely object + */ + createInstance: function(config) { + try { + config = config || {}; + + // TODO warn about setting per instance errorHandler / logger / logLevel + if (config.errorHandler) { + logging.setErrorHandler(config.errorHandler); + } + if (config.logger) { + logging.setLogHandler(config.logger); + // respect the logger's shouldLog functionality + logging.setLogLevel(logging.LogLevel.NOTSET); + } + if (config.logLevel !== undefined) { + logging.setLogLevel(config.logLevel); + } + + try { + configValidator.validate(config); + config.isValidInstance = true; + } catch (ex) { + logger.error(ex); + config.isValidInstance = false; + } + + // Explicitly check for null or undefined + // prettier-ignore + if (config.skipJSONValidation == null) { // eslint-disable-line eqeqeq + config.skipJSONValidation = true; + } + + config = fns.assignIn( + { + clientEngine: enums.JAVASCRIPT_CLIENT_ENGINE, + eventBatchSize: DEFAULT_EVENT_BATCH_SIZE, + eventFlushInterval: DEFAULT_EVENT_FLUSH_INTERVAL, + }, + config, + { + eventDispatcher: config.eventDispatcher, + // always get the OptimizelyLogger facade from logging + logger: logger, + errorHandler: logging.getErrorHandler(), + } + ); + + if (!eventProcessorConfigValidator.validateEventBatchSize(config.eventBatchSize)) { + logger.warn('Invalid eventBatchSize %s, defaulting to %s', config.eventBatchSize, DEFAULT_EVENT_BATCH_SIZE); + config.eventBatchSize = DEFAULT_EVENT_BATCH_SIZE; + } + if (!eventProcessorConfigValidator.validateEventFlushInterval(config.eventFlushInterval)) { + logger.warn('Invalid eventFlushInterval %s, defaulting to %s', config.eventFlushInterval, DEFAULT_EVENT_FLUSH_INTERVAL); + config.eventFlushInterval = DEFAULT_EVENT_FLUSH_INTERVAL; + } + + var optimizely = new Optimizely(config); + + return optimizely; + } catch (e) { + logger.error(e); + return null; + } + }, +}; diff --git a/packages/optimizely-sdk/lib/index.react_native.tests.js b/packages/optimizely-sdk/lib/index.react_native.tests.js new file mode 100644 index 000000000..74f7bcdb4 --- /dev/null +++ b/packages/optimizely-sdk/lib/index.react_native.tests.js @@ -0,0 +1,301 @@ +/** + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var logging = require('@optimizely/js-sdk-logging'); +var configValidator = require('./utils/config_validator'); +var eventProcessor = require('@optimizely/js-sdk-event-processor'); +var Optimizely = require('./optimizely'); +var optimizelyFactory = require('./index.react_native'); +var packageJSON = require('../package.json'); +var testData = require('./tests/test_data'); +var eventProcessor = require('@optimizely/js-sdk-event-processor'); +var eventProcessorConfigValidator = require('./utils/event_processor_config_validator'); + +var chai = require('chai'); +var assert = chai.assert; +var sinon = require('sinon'); + +describe('javascript-sdk/react-native', function() { + describe('APIs', function() { + var xhr; + var requests; + + it('should expose logger, errorHandler, eventDispatcher and enums', function() { + assert.isDefined(optimizelyFactory.logging); + assert.isDefined(optimizelyFactory.logging.createLogger); + assert.isDefined(optimizelyFactory.logging.createNoOpLogger); + assert.isDefined(optimizelyFactory.errorHandler); + assert.isDefined(optimizelyFactory.eventDispatcher); + assert.isDefined(optimizelyFactory.enums); + }); + + describe('createInstance', function() { + var fakeErrorHandler = { handleError: function() {} }; + var fakeEventDispatcher = { dispatchEvent: function() {} }; + var silentLogger; + + beforeEach(function() { + silentLogger = optimizelyFactory.logging.createLogger({ + logLevel: optimizelyFactory.enums.LOG_LEVEL.INFO, + logToConsole: false, + }); + sinon.spy(console, 'error'); + sinon.stub(configValidator, 'validate'); + + xhr = sinon.useFakeXMLHttpRequest(); + global.XMLHttpRequest = xhr; + requests = []; + xhr.onCreate = function(req) { + requests.push(req); + }; + }); + + afterEach(function() { + console.error.restore(); + configValidator.validate.restore(); + xhr.restore(); + }); + + it('should not throw if the provided config is not valid', function() { + configValidator.validate.throws(new Error('Invalid config or something')); + assert.doesNotThrow(function() { + var optlyInstance = optimizelyFactory.createInstance({ + datafile: {}, + logger: silentLogger, + }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); + }); + }); + + it('should create an instance of optimizely', function() { + var optlyInstance = optimizelyFactory.createInstance({ + datafile: {}, + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); + + assert.instanceOf(optlyInstance, Optimizely); + assert.equal(optlyInstance.clientVersion, '3.3.0'); + }); + + it('should set the JavaScript client engine and version', function() { + var optlyInstance = optimizelyFactory.createInstance({ + datafile: {}, + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); + assert.equal('javascript-sdk', optlyInstance.clientEngine); + assert.equal(packageJSON.version, optlyInstance.clientVersion); + }); + + it('should allow passing of "react-sdk" as the clientEngine', function() { + var optlyInstance = optimizelyFactory.createInstance({ + clientEngine: 'react-sdk', + datafile: {}, + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); + assert.equal('react-sdk', optlyInstance.clientEngine); + }); + + it('should activate with provided event dispatcher', function() { + var optlyInstance = optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfig(), + errorHandler: fakeErrorHandler, + eventDispatcher: optimizelyFactory.eventDispatcher, + logger: silentLogger, + }); + var activate = optlyInstance.activate('testExperiment', 'testUser'); + assert.strictEqual(activate, 'control'); + }); + + describe('when passing in logLevel', function() { + beforeEach(function() { + sinon.stub(logging, 'setLogLevel'); + }); + + afterEach(function() { + logging.setLogLevel.restore(); + }); + + it('should call logging.setLogLevel', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfig(), + logLevel: optimizelyFactory.enums.LOG_LEVEL.ERROR, + }); + sinon.assert.calledOnce(logging.setLogLevel); + sinon.assert.calledWithExactly(logging.setLogLevel, optimizelyFactory.enums.LOG_LEVEL.ERROR); + }); + }); + + describe('when passing in logger', function() { + beforeEach(function() { + sinon.stub(logging, 'setLogHandler'); + }); + + afterEach(function() { + logging.setLogHandler.restore(); + }); + + it('should call logging.setLogHandler with the supplied logger', function() { + var fakeLogger = { log: function() {} }; + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfig(), + logger: fakeLogger, + }); + sinon.assert.calledOnce(logging.setLogHandler); + sinon.assert.calledWithExactly(logging.setLogHandler, fakeLogger); + }); + }); + + describe('event processor configuration', function() { + var eventProcessorSpy; + beforeEach(function() { + eventProcessorSpy = sinon.stub(eventProcessor, 'LogTierV1EventProcessor').callThrough(); + }); + + afterEach(function() { + eventProcessor.LogTierV1EventProcessor.restore(); + }); + + it('should use default event flush interval when none is provided', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + flushInterval: 1000, + })); + }); + + describe('with an invalid flush interval', function() { + beforeEach(function() { + sinon.stub(eventProcessorConfigValidator, 'validateEventFlushInterval').returns(false); + }); + + afterEach(function() { + eventProcessorConfigValidator.validateEventFlushInterval.restore(); + }); + + it('should ignore the event flush interval and use the default instead', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + eventFlushInterval: ['invalid', 'flush', 'interval'], + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + flushInterval: 1000, + })); + }); + }); + + describe('with a valid flush interval', function() { + beforeEach(function() { + sinon.stub(eventProcessorConfigValidator, 'validateEventFlushInterval').returns(true); + }); + + afterEach(function() { + eventProcessorConfigValidator.validateEventFlushInterval.restore(); + }); + + it('should use the provided event flush interval', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + eventFlushInterval: 9000, + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + flushInterval: 9000, + })); + }); + }); + + it('should use default event batch size when none is provided', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + maxQueueSize: 10, + })); + }); + + describe('with an invalid event batch size', function() { + beforeEach(function() { + sinon.stub(eventProcessorConfigValidator, 'validateEventBatchSize').returns(false); + }); + + afterEach(function() { + eventProcessorConfigValidator.validateEventBatchSize.restore(); + }); + + it('should ignore the event batch size and use the default instead', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + eventBatchSize: null, + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + maxQueueSize: 10, + })); + }); + }); + + describe('with a valid event batch size', function() { + beforeEach(function() { + sinon.stub(eventProcessorConfigValidator, 'validateEventBatchSize').returns(true); + }); + + afterEach(function() { + eventProcessorConfigValidator.validateEventBatchSize.restore(); + }); + + it('should use the provided event batch size', function() { + optimizelyFactory.createInstance({ + datafile: testData.getTestProjectConfigWithFeatures(), + errorHandler: fakeErrorHandler, + eventDispatcher: fakeEventDispatcher, + logger: silentLogger, + eventBatchSize: 300, + }); + sinon.assert.calledWithExactly(eventProcessorSpy, sinon.match({ + maxQueueSize: 300, + })); + }); + }); + }); + }); + }); +}); diff --git a/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js new file mode 100644 index 000000000..54c74ebe2 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.js @@ -0,0 +1,53 @@ +/** + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var LogLevel = require('@optimizely/js-sdk-logging').LogLevel; + +function getLogLevelName(level) { + switch(level) { + case LogLevel.INFO: return 'INFO'; + case LogLevel.ERROR: return 'ERROR'; + case LogLevel.WARNING: return 'WARNING'; + case LogLevel.DEBUG: return 'DEBUG'; + default: return 'NOTSET'; + } +} + +class ReactNativeLogger { + log(level, message) { + const formattedMessage = `[OPTIMIZELY] - ${getLogLevelName(level)} ${new Date().toISOString()} ${message}`; + switch (level) { + case LogLevel.INFO: console.info(formattedMessage); break; + case LogLevel.ERROR: + case LogLevel.WARNING: console.warn(formattedMessage); break; + case LogLevel.DEBUG: + case LogLevel.NOTSET: console.log(formattedMessage); break; + } + } +} + +function NoOpLogger() {} + +NoOpLogger.prototype.log = function() {}; + +module.exports = { + createLogger: function() { + return new ReactNativeLogger(); + }, + + createNoOpLogger: function() { + return new NoOpLogger(); + }, +}; diff --git a/packages/optimizely-sdk/lib/plugins/logger/index.react_native.tests.js b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.tests.js new file mode 100644 index 000000000..15553fbe8 --- /dev/null +++ b/packages/optimizely-sdk/lib/plugins/logger/index.react_native.tests.js @@ -0,0 +1,83 @@ +/** + * Copyright 2019, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var logger = require('./index.react_native'); +var chai = require('chai'); +var enums = require('../../utils/enums'); +var assert = chai.assert; +var sinon = require('sinon'); + +var LOG_LEVEL = enums.LOG_LEVEL; +describe('lib/plugins/logger/react_native', function() { + describe('APIs', function() { + var defaultLogger; + describe('createLogger', function() { + it('should return an instance of the default logger', function() { + defaultLogger = logger.createLogger(); + assert.isObject(defaultLogger); + }); + }); + + describe('log', function() { + beforeEach(function() { + defaultLogger = logger.createLogger(); + + sinon.stub(console, 'log'); + sinon.stub(console, 'info'); + sinon.stub(console, 'warn'); + sinon.stub(console, 'error'); + }); + + afterEach(function() { + console.log.restore(); + console.info.restore(); + console.warn.restore(); + console.error.restore(); + }); + + it('shoud use console.info when log level is info', function() { + defaultLogger.log(LOG_LEVEL.INFO, 'message'); + sinon.assert.calledWithExactly(console.info, sinon.match(/.*INFO.*message.*/)); + sinon.assert.notCalled(console.log); + sinon.assert.notCalled(console.warn); + sinon.assert.notCalled(console.error); + }); + + it('shoud use console.log when log level is debug', function() { + defaultLogger.log(LOG_LEVEL.DEBUG, 'message'); + sinon.assert.calledWithExactly(console.log, sinon.match(/.*DEBUG.*message.*/)); + sinon.assert.notCalled(console.info); + sinon.assert.notCalled(console.warn); + sinon.assert.notCalled(console.error); + }); + + it('shoud use console.warn when log level is warn', function() { + defaultLogger.log(LOG_LEVEL.WARNING, 'message'); + sinon.assert.calledWithExactly(console.warn, sinon.match(/.*WARNING.*message.*/)); + sinon.assert.notCalled(console.log); + sinon.assert.notCalled(console.info); + sinon.assert.notCalled(console.error); + }); + + it('shoud use console.warn when log level is error', function() { + defaultLogger.log(LOG_LEVEL.ERROR, 'message'); + sinon.assert.calledWithExactly(console.warn, sinon.match(/.*ERROR.*message.*/)); + sinon.assert.notCalled(console.log); + sinon.assert.notCalled(console.info); + sinon.assert.notCalled(console.error); + }); + }); + }); +}); diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index a6ded5221..64340c395 100644 --- a/packages/optimizely-sdk/package.json +++ b/packages/optimizely-sdk/package.json @@ -4,6 +4,7 @@ "description": "JavaScript SDK for Optimizely X Full Stack", "main": "lib/index.node.js", "browser": "lib/index.browser.js", + "react-native": "lib/index.react_native.js", "typings": "lib/index.d.ts", "scripts": { "test": "mocha ./lib/*.tests.js ./lib/**/*.tests.js ./lib/**/**/*tests.js --recursive --exit --require lib/tests/exit_on_unhandled_rejection.js",