From 5fc68f3dfd8cb9dfc390e73f0fb9dde2c587f8f2 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 15 Mar 2019 15:42:17 -0700 Subject: [PATCH 01/42] feat (datafile management) [OASIS-4308]: Base implementation of HTTP polling datafile manager (#1) Summary: Add the datafile-management package Add the implementation of HTTPPollingDatafileManager, an abstract class that handles most datafile manager logic Making the GET request and setting the timeout are left as abstract methods - browser and node datafile manager classes will implement these. Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4308 --- packages/datafile-manager/.gitignore | 1 + packages/datafile-manager/.nvmrc | 1 + packages/datafile-manager/CHANGELOG.md | 12 + packages/datafile-manager/LICENSE | 202 + packages/datafile-manager/README.md | 13 + .../__test__/eventEmitter.spec.ts | 100 + .../httpPollingDatafileManager.spec.ts | 403 ++ packages/datafile-manager/jest.config.js | 195 + packages/datafile-manager/package.json | 46 + packages/datafile-manager/src/config.ts | 23 + .../datafile-manager/src/datafileManager.ts | 56 + packages/datafile-manager/src/eventEmitter.ts | 62 + packages/datafile-manager/src/http.ts | 25 + .../src/httpPollingDatafileManager.ts | 259 ++ .../datafile-manager/src/timeoutFactory.ts | 12 + packages/datafile-manager/tsconfig.json | 12 + packages/datafile-manager/yarn.lock | 3454 +++++++++++++++++ 17 files changed, 4876 insertions(+) create mode 100644 packages/datafile-manager/.gitignore create mode 100644 packages/datafile-manager/.nvmrc create mode 100644 packages/datafile-manager/CHANGELOG.md create mode 100644 packages/datafile-manager/LICENSE create mode 100644 packages/datafile-manager/README.md create mode 100644 packages/datafile-manager/__test__/eventEmitter.spec.ts create mode 100644 packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts create mode 100644 packages/datafile-manager/jest.config.js create mode 100644 packages/datafile-manager/package.json create mode 100644 packages/datafile-manager/src/config.ts create mode 100644 packages/datafile-manager/src/datafileManager.ts create mode 100644 packages/datafile-manager/src/eventEmitter.ts create mode 100644 packages/datafile-manager/src/http.ts create mode 100644 packages/datafile-manager/src/httpPollingDatafileManager.ts create mode 100644 packages/datafile-manager/src/timeoutFactory.ts create mode 100644 packages/datafile-manager/tsconfig.json create mode 100644 packages/datafile-manager/yarn.lock diff --git a/packages/datafile-manager/.gitignore b/packages/datafile-manager/.gitignore new file mode 100644 index 000000000..a65b41774 --- /dev/null +++ b/packages/datafile-manager/.gitignore @@ -0,0 +1 @@ +lib diff --git a/packages/datafile-manager/.nvmrc b/packages/datafile-manager/.nvmrc new file mode 100644 index 000000000..e338b8659 --- /dev/null +++ b/packages/datafile-manager/.nvmrc @@ -0,0 +1 @@ +v10 diff --git a/packages/datafile-manager/CHANGELOG.md b/packages/datafile-manager/CHANGELOG.md new file mode 100644 index 000000000..e88980cae --- /dev/null +++ b/packages/datafile-manager/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [Unreleased] +Changes that have landed but are not yet released. + +## [0.1.0] - March 4, 2019 + +Initial release diff --git a/packages/datafile-manager/LICENSE b/packages/datafile-manager/LICENSE new file mode 100644 index 000000000..b9f80c5bd --- /dev/null +++ b/packages/datafile-manager/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017, Optimizely, Inc. and contributors + + 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. diff --git a/packages/datafile-manager/README.md b/packages/datafile-manager/README.md new file mode 100644 index 000000000..dabba157f --- /dev/null +++ b/packages/datafile-manager/README.md @@ -0,0 +1,13 @@ +# Javascript SDK Datafile Manager + +TODO: Write this + +## Installation + +```sh +npm install @optimizely/datafile-manager +``` + +## Usage + +TODO diff --git a/packages/datafile-manager/__test__/eventEmitter.spec.ts b/packages/datafile-manager/__test__/eventEmitter.spec.ts new file mode 100644 index 000000000..acfa96f1f --- /dev/null +++ b/packages/datafile-manager/__test__/eventEmitter.spec.ts @@ -0,0 +1,100 @@ +import EventEmitter from '../src/eventEmitter' + +describe('event_emitter', () => { + describe('on', () => { + let emitter: EventEmitter; + beforeEach(() => { + emitter = new EventEmitter() + }) + + it('can add a listener for the update event', () => { + const listener = jest.fn() + emitter.on('update', listener) + emitter.emit('update', { datafile: 'abcd' }) + expect(listener).toBeCalledTimes(1) + }) + + it('passes the argument from emit to the listener', () => { + const listener = jest.fn() + emitter.on('update', listener) + emitter.emit('update', { datafile: 'abcd' }) + expect(listener).toBeCalledWith({ datafile: 'abcd' }) + }) + + it('returns a dispose function that removes the listener', () => { + const listener = jest.fn() + const disposer = emitter.on('update', listener) + disposer() + emitter.emit('update', { datafile: 'efgh' }) + expect(listener).toBeCalledTimes(0) + }) + + it('can add several listeners for the update event', () => { + const listener1 = jest.fn() + const listener2 = jest.fn() + const listener3 = jest.fn() + emitter.on('update', listener1) + emitter.on('update', listener2) + emitter.on('update', listener3) + emitter.emit('update', { datafile: 'abcd' }) + expect(listener1).toBeCalledTimes(1) + expect(listener2).toBeCalledTimes(1) + expect(listener3).toBeCalledTimes(1) + }) + + it('can add several listeners and remove only some of them', () => { + const listener1 = jest.fn() + const listener2 = jest.fn() + const listener3 = jest.fn() + const disposer1 = emitter.on('update', listener1) + const disposer2 = emitter.on('update', listener2) + emitter.on('update', listener3) + emitter.emit('update', { datafile: 'abcd' }) + expect(listener1).toBeCalledTimes(1) + expect(listener2).toBeCalledTimes(1) + expect(listener3).toBeCalledTimes(1) + disposer1() + disposer2() + emitter.emit('update', { datafile: 'efgh' }) + expect(listener1).toBeCalledTimes(1) + expect(listener2).toBeCalledTimes(1) + expect(listener3).toBeCalledTimes(2) + }) + + it('can add listeners for different events and remove only some of them', () => { + const readyListener = jest.fn() + const updateListener = jest.fn() + const readyDisposer = emitter.on('ready', readyListener) + const updateDisposer = emitter.on('update', updateListener) + emitter.emit('ready') + expect(readyListener).toBeCalledTimes(1) + expect(updateListener).toBeCalledTimes(0) + emitter.emit('update', { datafile: 'abcd' }) + expect(readyListener).toBeCalledTimes(1) + expect(updateListener).toBeCalledTimes(1) + readyDisposer() + emitter.emit('ready') + expect(readyListener).toBeCalledTimes(1) + expect(updateListener).toBeCalledTimes(1) + emitter.emit('update', { datafile: 'efgh' }) + expect(readyListener).toBeCalledTimes(1) + expect(updateListener).toBeCalledTimes(2) + updateDisposer() + emitter.emit('update', { datafile: 'ijkl' }) + expect(readyListener).toBeCalledTimes(1) + expect(updateListener).toBeCalledTimes(2) + }) + + it('can remove all listeners', () => { + const readyListener = jest.fn() + const updateListener = jest.fn() + emitter.on('ready', readyListener) + emitter.on('update', updateListener) + emitter.removeAllListeners() + emitter.emit('update', { datafile: 'abcd' }) + emitter.emit('ready') + expect(readyListener).toBeCalledTimes(0) + expect(updateListener).toBeCalledTimes(0) + }) + }) +}) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts new file mode 100644 index 000000000..e955f2f58 --- /dev/null +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -0,0 +1,403 @@ +/** + * 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. + */ + +import HTTPPollingDatafileManager from '../src/httpPollingDatafileManager' +import { Headers, Response } from '../src/http' +import { TimeoutFactory } from '../src/timeoutFactory' +import { DatafileManagerConfig } from '../src/datafileManager'; + +class TestTimeoutFactory implements TimeoutFactory { + timeoutFns: Array<() => void> = [] + + cancelFns: Array<() => void> = [] + + setTimeout(onTimeout: () => void, timeout: number): () => void { + const cancelFn = jest.fn() + this.timeoutFns.push(() => { + onTimeout() + }) + this.cancelFns.push(cancelFn) + return cancelFn + } + + cleanup() { + this.timeoutFns = [] + this.cancelFns = [] + } +} + +// Test implementation: +// - Does not make any real requests: just resolves with queued responses (tests push onto queuedResponses) +class TestDatafileManager extends HTTPPollingDatafileManager { + queuedResponses: Response[] = [] + + responsePromises: Promise[] = [] + + makeGetRequest(url: string, headers: Headers): Promise { + const nextResponse: Response | undefined = this.queuedResponses.pop() + if (nextResponse === undefined) { + return Promise.reject('No responses queued') + } + const respPromise = Promise.resolve(nextResponse) + this.responsePromises.push(respPromise) + return respPromise + } +} + +describe('httpPollingDatafileManager', () => { + const testTimeoutFactory: TestTimeoutFactory = new TestTimeoutFactory() + + function createTestManager(config: DatafileManagerConfig): TestDatafileManager { + return new TestDatafileManager({ + ...config, + timeoutFactory: testTimeoutFactory + }) + } + + let manager: TestDatafileManager + afterEach(async () => { + testTimeoutFactory.cleanup() + + if (manager) { + manager.stop() + } + jest.restoreAllMocks() + }) + + describe('when constructed with sdkKey and datafile', () => { + beforeEach(() => { + manager = createTestManager({ datafile: 'abcd', sdkKey: '123' }) + }) + + it('returns the passed datafile from get', () => { + expect(manager.get()).toBe('abcd') + }) + + it('after being started, fetches the datafile and resolves onReady', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {} + }) + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + }) + }) + + describe('when constructed with sdkKey only', () => { + beforeEach(() => { + manager = createTestManager({ sdkKey: '123', updateInterval: 10 }) + }) + + afterEach(() => { + manager.stop() + }) + + describe('initial state', () => { + it('returns null from get before becoming ready', () => { + expect(manager.get()).toBeNull() + }) + }) + + describe('started state', () => { + it('passes the default datafile URL to the makeGetRequest method', async () => { + const makeGetRequestSpy = jest.spyOn(manager, 'makeGetRequest') + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + manager.start() + expect(makeGetRequestSpy).toBeCalledTimes(1) + expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/123.json') + await manager.onReady + }) + + it('after being started, fetches the datafile and resolves onReady', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + }) + + describe('live updates', () => { + it('passes the update interval to its timeoutFactory setTimeout method', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo3": "bar3"}', + headers: {}, + }) + + const setTimeoutSpy: jest.SpyInstance<() => void, [() => void, number]> = jest.spyOn(testTimeoutFactory, 'setTimeout') + + manager.start() + await manager.onReady + expect(setTimeoutSpy).toBeCalledTimes(1) + expect(setTimeoutSpy.mock.calls[0][1]).toBe(10) + }) + + it('emits update events after live updates', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo3": "bar3"}', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo2": "bar2"}', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }, + ) + + const updateFn = jest.fn() + manager.on('update', updateFn) + + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + expect(updateFn).toBeCalledTimes(0) + + testTimeoutFactory.timeoutFns[0]() + await manager.responsePromises[1] + expect(updateFn).toBeCalledTimes(1) + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo2": "bar2"}' }) + expect(manager.get()).toBe('{"foo2": "bar2"}') + + updateFn.mockReset() + + testTimeoutFactory.timeoutFns[1]() + await manager.responsePromises[2] + expect(updateFn).toBeCalledTimes(1) + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo3": "bar3"}' }) + expect(manager.get()).toBe('{"foo3": "bar3"}') + }) + + it('cancels a pending timeout when stop is called', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }, + ) + + manager.start() + await manager.onReady + + expect(testTimeoutFactory.timeoutFns.length).toBe(1) + expect(testTimeoutFactory.cancelFns.length).toBe(1) + manager.stop() + expect(testTimeoutFactory.cancelFns[0]).toBeCalledTimes(1) + }) + + it('cancels reactions to a pending fetch when stop is called', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo2": "bar2"}', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }, + ) + + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + testTimeoutFactory.timeoutFns[0]() + expect(manager.responsePromises.length).toBe(2) + manager.stop() + await manager.responsePromises[1] + // Should not have updated datafile since manager was stopped + expect(manager.get()).toBe('{"foo": "bar"}') + }) + + it('can fail to become ready on the initial request, but succeed after a later polling update', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }, + { + statusCode: 404, + body: '', + headers: {} + } + ) + + manager.start() + expect(manager.responsePromises.length).toBe(1) + await manager.responsePromises[0] + // Not ready yet due to first request failed, but should have queued a live update + expect(testTimeoutFactory.timeoutFns.length).toBe(1) + // Trigger the update, should fetch the next response which should succeed, then we get ready + testTimeoutFactory.timeoutFns[0]() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + }) + + describe('newness checking', () => { + it('does not update if the response status is 304', async () => { + manager.queuedResponses.push( + { + statusCode: 304, + body: '', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: { + 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + } + ) + + const updateFn = jest.fn() + manager.on('update', updateFn) + + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + // First response promise was for the initial 200 response + expect(manager.responsePromises.length).toBe(1) + // Trigger the queued update + testTimeoutFactory.timeoutFns[0]() + // Second response promise is for the 304 response + expect(manager.responsePromises.length).toBe(2) + await manager.responsePromises[1] + // Since the response was 304, updateFn should not have been called + expect(updateFn).toBeCalledTimes(0) + expect(manager.get()).toBe('{"foo": "bar"}') + }) + + it('sends if-modified-since using the last observed response last-modified', async () => { + manager.queuedResponses.push( + { + statusCode: 304, + body: '', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: { + 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + } + ) + manager.start() + await manager.onReady + const makeGetRequestSpy = jest.spyOn(manager, 'makeGetRequest') + testTimeoutFactory.timeoutFns[0]() + expect(makeGetRequestSpy).toBeCalledTimes(1) + const firstCall = makeGetRequestSpy.mock.calls[0] + const headers = firstCall[1] + expect(headers).toEqual({ + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT', + }) + }) + }) + }) + }) + }) + + describe('when constructed with sdkKey and liveUpdates: false', () => { + beforeEach(() => { + manager = createTestManager({ sdkKey: '123', liveUpdates: false }) + }) + + it('after being started, fetches the datafile and resolves onReady', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + manager.start() + await manager.onReady + expect(manager.get()).toBe('{"foo": "bar"}') + }) + + it('does not schedule a live update after ready', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + const updateFn = jest.fn() + manager.on('update', updateFn) + manager.start() + await manager.onReady + expect(testTimeoutFactory.timeoutFns.length).toBe(0) + }) + + // TODO: figure out what's wrong with this test + it.skip('rejects the onReady promise if the initial request promise rejects', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + manager.makeGetRequest = () => Promise.reject(new Error('Could not connect')) + manager.start() + let didReject = false + try { + await manager.onReady + } catch (e) { + didReject = true + } + expect(didReject).toBe(true) + }) + }) + + describe('when constructed with sdkKey and a valid urlTemplate', () => { + beforeEach(() => { + manager = createTestManager({ + sdkKey: '456', + updateInterval: 10, + urlTemplate: 'https://localhost:5556/datafiles/$SDK_KEY', + }) + }) + + it('uses the urlTemplate to create the url passed to the makeGetRequest method', async () => { + const makeGetRequestSpy = jest.spyOn(manager, 'makeGetRequest') + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }) + manager.start() + expect(makeGetRequestSpy).toBeCalledTimes(1) + expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://localhost:5556/datafiles/456') + await manager.onReady + }) + }) +}) diff --git a/packages/datafile-manager/jest.config.js b/packages/datafile-manager/jest.config.js new file mode 100644 index 000000000..a260bda52 --- /dev/null +++ b/packages/datafile-manager/jest.config.js @@ -0,0 +1,195 @@ +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +module.exports = { + // All imported modules in your tests should be mocked automatically + // automock: false, + + // Stop running tests after `n` failures + // bail: 0, + + // Respect "browser" field in package.json when resolving modules + // browser: false, + + // The directory where Jest should store its cached dependency information + // cacheDirectory: "/private/var/folders/rz/km3gywzs7h9c5wq67w1gy9l8dc02_d/T/jest_7f76tp", + + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + // collectCoverage: false, + + // An array of glob patterns indicating a set of files for which coverage information should be collected + // collectCoverageFrom: null, + + // The directory where Jest should output its coverage files + // coverageDirectory: null, + + // An array of regexp pattern strings used to skip coverage collection + // coveragePathIgnorePatterns: [ + // "/node_modules/" + // ], + + // A list of reporter names that Jest uses when writing coverage reports + // coverageReporters: [ + // "json", + // "text", + // "lcov", + // "clover" + // ], + + // An object that configures minimum threshold enforcement for coverage results + // coverageThreshold: null, + + // A path to a custom dependency extractor + // dependencyExtractor: null, + + // Make calling deprecated APIs throw helpful error messages + // errorOnDeprecated: false, + + // Force coverage collection from ignored files usin a array of glob patterns + // forceCoverageMatch: [], + + // A path to a module which exports an async function that is triggered once before all test suites + // globalSetup: null, + + // A path to a module which exports an async function that is triggered once after all test suites + // globalTeardown: null, + + // A set of global variables that need to be available in all test environments + // globals: {}, + + // An array of directory names to be searched recursively up from the requiring module's location + // moduleDirectories: [ + // "node_modules" + // ], + + // An array of file extensions your modules use + // moduleFileExtensions: [ + // "js", + // "json", + // "jsx", + // "ts", + // "tsx", + // "node" + // ], + "moduleFileExtensions": [ + "ts", + "js", + "json", + "node" + ], + + // A map from regular expressions to module names that allow to stub out resources with a single module + // moduleNameMapper: {}, + + // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader + // modulePathIgnorePatterns: [], + + // Activates notifications for test results + // notify: false, + + // An enum that specifies notification mode. Requires { notify: true } + // notifyMode: "failure-change", + + // A preset that is used as a base for Jest's configuration + // preset: null, + + // Run tests from one or more projects + // projects: null, + + // Use this configuration option to add custom reporters to Jest + // reporters: undefined, + + // Automatically reset mock state between every test + // resetMocks: false, + + // Reset the module registry before running each individual test + // resetModules: false, + + // A path to a custom resolver + // resolver: null, + + // Automatically restore mock state between every test + // restoreMocks: false, + + // The root directory that Jest should scan for tests and modules within + // rootDir: null, + + // A list of paths to directories that Jest should use to search for files in + // roots: [ + // "" + // ], + + // Allows you to use a custom runner instead of Jest's default test runner + // runner: "jest-runner", + + // The paths to modules that run some code to configure or set up the testing environment before each test + // setupFiles: [], + + // A list of paths to modules that run some code to configure or set up the testing framework before each test + // setupFilesAfterEnv: [], + + // A list of paths to snapshot serializer modules Jest should use for snapshot testing + // snapshotSerializers: [], + + // The test environment that will be used for testing + testEnvironment: "node", + + // Options that will be passed to the testEnvironment + // testEnvironmentOptions: {}, + + // Adds a location field to test results + // testLocationInResults: false, + + // The glob patterns Jest uses to detect test files + // testMatch: [ + // "**/__tests__/**/*.[jt]s?(x)", + // "**/?(*.)+(spec|test).[tj]s?(x)" + // ], + + // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped + // testPathIgnorePatterns: [ + // "/node_modules/" + // ], + + // The regexp pattern or array of patterns that Jest uses to detect test files + // testRegex: [], + "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.ts$", + + // This option allows the use of a custom results processor + // testResultsProcessor: null, + + // This option allows use of a custom test runner + // testRunner: "jasmine2", + + // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href + // testURL: "http://localhost", + + // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" + // timers: "real", + + // A map from regular expressions to paths to transformers + // transform: null, + "transform": { + "^.+\\.ts$": "ts-jest" + }, + + // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation + // transformIgnorePatterns: [ + // "/node_modules/" + // ], + + // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them + // unmockedModulePathPatterns: undefined, + + // Indicates whether each individual test should be reported during the run + // verbose: null, + + // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode + // watchPathIgnorePatterns: [], + + // Whether to use watchman for file crawling + // watchman: true, +}; diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json new file mode 100644 index 000000000..172f62eb0 --- /dev/null +++ b/packages/datafile-manager/package.json @@ -0,0 +1,46 @@ +{ + "name": "datafile-manager", + "version": "0.1.0", + "description": "Optimizely Full Stack Datafile Manager", + "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/datafile-manager", + "license": "MIT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib", + "test": "__test__" + }, + "files": [ + "lib", + "LICENSE", + "CHANGELOG", + "README.md", + "package.json" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/optimizely/javascript-sdk.git" + }, + "keywords": [ + "optimizely" + ], + "bugs": { + "url": "https://github.com/optimizely/javascript-sdk/issues" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/jest": "^24.0.9", + "jest": "^24.1.0", + "ts-jest": "^24.0.0", + "typescript": "^3.3.3333" + }, + "dependencies": { + "@optimizely/js-sdk-logging": "^0.1.0", + "@types/node": "^11.10.5" + }, + "scripts": { + "test": "jest" + } +} diff --git a/packages/datafile-manager/src/config.ts b/packages/datafile-manager/src/config.ts new file mode 100644 index 000000000..7e57238cb --- /dev/null +++ b/packages/datafile-manager/src/config.ts @@ -0,0 +1,23 @@ +/** + * 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. + */ + +export const DEFAULT_UPDATE_INTERVAL = 5000 + +export const MIN_UPDATE_INTERVAL = 1 + +export const SDK_KEY_TOKEN = '$SDK_KEY' + +export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/${SDK_KEY_TOKEN}.json` diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts new file mode 100644 index 000000000..c22d37c06 --- /dev/null +++ b/packages/datafile-manager/src/datafileManager.ts @@ -0,0 +1,56 @@ +import { TimeoutFactory } from "./timeoutFactory"; + +/** + * 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. + */ + + export interface DatafileUpdate { + datafile: string + } + +export interface DatafileUpdateListener { + (datafileUpdate: DatafileUpdate): void +} + +// TODO: Replace this with the one from js-sdk-models +interface Managed { + start(): void + + stop(): Promise +} + +export interface DatafileManager extends Managed { + get: () => string | null + on: (eventName: string, listener: DatafileUpdateListener) => () => void + onReady: Promise +} + +export enum CacheDirective { + // Use cache entry as fallback, but wait for CDN sync before onReady + AWAIT = 'await', + // Use cache entry for onReady, and do CDN sync in the background + DONT_AWAIT = 'dontawait', +} + +export interface DatafileManagerConfig { + cacheDirective?: CacheDirective + datafile?: string + liveUpdates?: boolean + maxCacheAge?: number + sdkKey: string + timeoutFactory?: TimeoutFactory, + updateInterval?: number + urlTemplate?: string +} diff --git a/packages/datafile-manager/src/eventEmitter.ts b/packages/datafile-manager/src/eventEmitter.ts new file mode 100644 index 000000000..f8bc4ef78 --- /dev/null +++ b/packages/datafile-manager/src/eventEmitter.ts @@ -0,0 +1,62 @@ +/** + * 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. + */ + +export type Disposer = () => void + +export type Listener = (arg?: any) => void + +interface Listeners { + [index: string]: { // index is event name + [index: string]: Listener // index is listener id + } +} + +export default class EventEmitter { + private listeners: Listeners = {} + + private listenerId = 1 + + on(eventName: string, listener: Listener): Disposer { + if (!this.listeners[eventName]) { + this.listeners[eventName] = {} + } + const currentListenerId = String(this.listenerId) + this.listenerId++ + this.listeners[eventName][currentListenerId] = listener + return () => { + if (this.listeners[eventName]) { + delete this.listeners[eventName][currentListenerId] + } + } + } + + emit(eventName: string, arg?: any) { + const listeners = this.listeners[eventName] + if (listeners) { + Object.keys(listeners).forEach(listenerId => { + const listener = listeners[listenerId] + listener(arg) + }) + } + } + + removeAllListeners(): void { + this.listeners = {} + } +} + + +// TODO: Create a typed event emitter for use in TS only (not JS) diff --git a/packages/datafile-manager/src/http.ts b/packages/datafile-manager/src/http.ts new file mode 100644 index 000000000..b94d5b169 --- /dev/null +++ b/packages/datafile-manager/src/http.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +export interface Headers { + [header: string]: string | string[] | undefined; +} + +export interface Response { + statusCode?: number + body: string + headers: Headers +} diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts new file mode 100644 index 000000000..4a838a4df --- /dev/null +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -0,0 +1,259 @@ +/** + * 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. + */ + +import { getLogger } from '@optimizely/js-sdk-logging' +import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'; +import EventEmitter from './eventEmitter' +import { Response, Headers } from './http'; +import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE, SDK_KEY_TOKEN } from './config' +import { TimeoutFactory, DEFAULT_TIMEOUT_FACTORY } from './timeoutFactory' + +const logger = getLogger('DatafileManager') + +const UPDATE_EVT = 'update' + +function isValidUpdateInterval(updateInterval: number): boolean { + return updateInterval >= MIN_UPDATE_INTERVAL +} + +export default abstract class HTTPPollingDatafileManager implements DatafileManager { + // Make an HTTP get request to the given URL with the given headers + // Return a promise for a Response. If we can't get a response, the promise is rejected + protected abstract makeGetRequest(reqUrl: string, headers: Headers): Promise + + public readonly onReady: Promise + + private currentDatafile: string | null + + private readonly sdkKey: string + + private isOnReadySettled: boolean + + private onReadyResolver: () => void + + private onReadyRejecter: (err: Error) => void + + private readonly emitter: EventEmitter + + private readonly liveUpdates: boolean + + private readonly updateInterval: number + + private cancelTimeout?: () => void + + private isStarted: boolean + + private lastResponseLastModified?: string + + private urlTemplate: string + + private timeoutFactory: TimeoutFactory + + constructor(config: DatafileManagerConfig) { + const { + datafile, + liveUpdates = true, + sdkKey, + timeoutFactory = DEFAULT_TIMEOUT_FACTORY, + updateInterval = DEFAULT_UPDATE_INTERVAL, + urlTemplate = DEFAULT_URL_TEMPLATE, + } = config + + this.sdkKey = sdkKey + + if (typeof datafile !== 'undefined') { + this.currentDatafile = datafile + } else { + this.currentDatafile = null + } + + this.isOnReadySettled = false + this.onReadyResolver = () => {} + this.onReadyRejecter = () => {} + this.onReady = new Promise((resolve, reject) => { + this.onReadyResolver = resolve + this.onReadyRejecter = reject + }) + + this.isStarted = false + + this.urlTemplate = urlTemplate + if (this.urlTemplate.indexOf(SDK_KEY_TOKEN) === -1) { + logger.debug(`urlTemplate does not contain replacement token ${SDK_KEY_TOKEN}`) + } + + this.timeoutFactory = timeoutFactory + this.emitter = new EventEmitter() + this.liveUpdates = liveUpdates + if (isValidUpdateInterval(updateInterval)) { + this.updateInterval = updateInterval + } else { + logger.warn('Invalid updateInterval %s, defaulting to %s', updateInterval, DEFAULT_UPDATE_INTERVAL) + this.updateInterval = DEFAULT_UPDATE_INTERVAL + } + } + + get(): string | null { + return this.currentDatafile + } + + start(): void { + if (!this.isStarted) { + logger.debug('Datafile manager started') + this.isStarted = true + this.syncDatafile() + } + } + + stop(): Promise { + logger.debug('Datafile manager stopped') + this.isStarted = false + if (this.cancelTimeout) { + this.cancelTimeout() + this.cancelTimeout = undefined + } + this.emitter.removeAllListeners() + return Promise.resolve() + } + + on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void) { + return this.emitter.on(eventName, listener) + } + + private getUrl(sdkKey: string) { + return this.urlTemplate.replace(SDK_KEY_TOKEN, sdkKey) + } + + private logMakeGetRequestError(err: any): void { + if (!this.isStarted) { + return + } + + if (err instanceof Error) { + logger.error('Error fetching datafile: %s', err.message, err) + } else if (typeof err === 'string') { + logger.error('Error fetching datafile: %s', err) + } else { + logger.error('Error fetching datafile') + } + } + + private tryUpdatingDatafile(response: Response): void { + if (!this.isStarted) { + return + } + + this.trySavingLastModified(response.headers) + + const datafile = this.getNextDatafileFromResponse(response) + if (datafile !== null) { + logger.info('Updating datafile from response') + this.currentDatafile = datafile + if (!this.isOnReadySettled) { + this.resolveOnReady() + } else if (this.liveUpdates) { + const datafileUpdate: DatafileUpdate = { + datafile, + } + this.emitter.emit(UPDATE_EVT, datafileUpdate) + } + } + } + + private onFetchComplete(this: HTTPPollingDatafileManager): void { + if (!this.isStarted) { + return + } + + if (this.liveUpdates) { + this.scheduleNextUpdate() + } + if (!this.isOnReadySettled && !this.liveUpdates) { + // We will never resolve ready, so reject it + this.rejectOnReady(new Error('Failed to become ready')) + } + } + + private syncDatafile(): void { + const headers: Headers = {} + if (this.lastResponseLastModified) { + headers['if-modified-since'] = this.lastResponseLastModified + } + + const datafileUrl = this.getUrl(this.sdkKey) + + logger.debug('Making datafile request to url %s with headers: %s', datafileUrl, () => JSON.stringify(headers)) + const datafileFetch = this.makeGetRequest(datafileUrl, headers) + + const onFetchComplete = () => { + this.onFetchComplete() + } + const tryUpdatingDatafile = (response: Response) => { + this.tryUpdatingDatafile(response) + } + const logMakeGetRequestError = (err: any) => { + this.logMakeGetRequestError(err) + } + datafileFetch + .then(tryUpdatingDatafile, logMakeGetRequestError) + .then(onFetchComplete, onFetchComplete) + } + + private resolveOnReady(): void { + this.onReadyResolver() + this.isOnReadySettled = true + } + + private rejectOnReady(err: Error): void { + this.onReadyRejecter(err) + this.isOnReadySettled = true + } + + private scheduleNextUpdate(): void { + logger.debug('Scheduling sync in %s ms', this.updateInterval) + this.cancelTimeout = this.timeoutFactory.setTimeout(() => { + this.syncDatafile() + }, this.updateInterval) + } + + private getNextDatafileFromResponse(response: Response): string | null { + logger.debug('Response status code: %s', response.statusCode) + if (typeof response.statusCode === 'undefined') { + return null + } + if (response.statusCode === 304) { + return null + } + if (response.statusCode >= 200 && response.statusCode < 400) { + return response.body + } + return null + } + + private trySavingLastModified(headers: Headers): void { + const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified'] + if (typeof lastModifiedHeader === 'string') { + this.lastResponseLastModified = lastModifiedHeader + logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified) + } else if (typeof lastModifiedHeader === 'undefined') { + } else { // array + if (lastModifiedHeader.length === 1) { + this.lastResponseLastModified = lastModifiedHeader[0] + logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified) + } + } + } +} diff --git a/packages/datafile-manager/src/timeoutFactory.ts b/packages/datafile-manager/src/timeoutFactory.ts new file mode 100644 index 000000000..c68ed22f6 --- /dev/null +++ b/packages/datafile-manager/src/timeoutFactory.ts @@ -0,0 +1,12 @@ +export interface TimeoutFactory { + setTimeout(onTimeout: () => void, timeout: number): () => void +} + +export const DEFAULT_TIMEOUT_FACTORY: TimeoutFactory = { + setTimeout(onTimeout: () => void, timeout: number) { + const timeoutId = setTimeout(onTimeout, timeout) + return () => { + clearTimeout(timeoutId) + } + } +} diff --git a/packages/datafile-manager/tsconfig.json b/packages/datafile-manager/tsconfig.json new file mode 100644 index 000000000..9c6e2911b --- /dev/null +++ b/packages/datafile-manager/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./lib" + }, + "include": [ + "./src" + ], + "exclude": [ + "./lib" + ] +} diff --git a/packages/datafile-manager/yarn.lock b/packages/datafile-manager/yarn.lock new file mode 100644 index 000000000..57cda2d58 --- /dev/null +++ b/packages/datafile-manager/yarn.lock @@ -0,0 +1,3454 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/code-frame@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8" + integrity sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA== + dependencies: + "@babel/highlight" "^7.0.0" + +"@babel/core@^7.1.0": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.3.4.tgz#921a5a13746c21e32445bf0798680e9d11a6530b" + integrity sha512-jRsuseXBo9pN197KnDwhhaaBzyZr2oIcLHHTt2oDdQrej5Qp57dCCJafWx5ivU8/alEYDpssYqv1MUqcxwQlrA== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.3.4" + "@babel/helpers" "^7.2.0" + "@babel/parser" "^7.3.4" + "@babel/template" "^7.2.2" + "@babel/traverse" "^7.3.4" + "@babel/types" "^7.3.4" + convert-source-map "^1.1.0" + debug "^4.1.0" + json5 "^2.1.0" + lodash "^4.17.11" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.0.0", "@babel/generator@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.3.4.tgz#9aa48c1989257877a9d971296e5b73bfe72e446e" + integrity sha512-8EXhHRFqlVVWXPezBW5keTiQi/rJMQTg/Y9uVCEZ0CAF3PKtCCaVRnp64Ii1ujhkoDhhF1fVsImoN4yJ2uz4Wg== + dependencies: + "@babel/types" "^7.3.4" + jsesc "^2.5.1" + lodash "^4.17.11" + source-map "^0.5.0" + trim-right "^1.0.1" + +"@babel/helper-function-name@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz#a0ceb01685f73355d4360c1247f582bfafc8ff53" + integrity sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw== + dependencies: + "@babel/helper-get-function-arity" "^7.0.0" + "@babel/template" "^7.1.0" + "@babel/types" "^7.0.0" + +"@babel/helper-get-function-arity@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz#83572d4320e2a4657263734113c42868b64e49c3" + integrity sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helper-plugin-utils@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250" + integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA== + +"@babel/helper-split-export-declaration@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.0.0.tgz#3aae285c0311c2ab095d997b8c9a94cad547d813" + integrity sha512-MXkOJqva62dfC0w85mEf/LucPPS/1+04nmmRMPEBUB++hiiThQ2zPtX/mEWQ3mtzCEjIJvPY8nuwxXtQeQwUag== + dependencies: + "@babel/types" "^7.0.0" + +"@babel/helpers@^7.2.0": + version "7.3.1" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.3.1.tgz#949eec9ea4b45d3210feb7dc1c22db664c9e44b9" + integrity sha512-Q82R3jKsVpUV99mgX50gOPCWwco9Ec5Iln/8Vyu4osNIOQgSrd9RFrQeUvmvddFNoLwMyOUWU+5ckioEKpDoGA== + dependencies: + "@babel/template" "^7.1.2" + "@babel/traverse" "^7.1.5" + "@babel/types" "^7.3.0" + +"@babel/highlight@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" + integrity sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.0.0", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.3.4.tgz#a43357e4bbf4b92a437fb9e465c192848287f27c" + integrity sha512-tXZCqWtlOOP4wgCp6RjRvLmfuhnqTLy9VHwRochJBCP2nDm27JnnuFEnXFASVyQNHk36jD1tAammsCEEqgscIQ== + +"@babel/plugin-syntax-object-rest-spread@^7.0.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e" + integrity sha512-t0JKGgqk2We+9may3t0xDdmneaXmyxq0xieYcKHxIsrJO64n1OiMWNUtc5gQK1PA0NpdCRrtZp4z+IUaKugrSA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + +"@babel/template@^7.0.0", "@babel/template@^7.1.0", "@babel/template@^7.1.2", "@babel/template@^7.2.2": + version "7.2.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.2.2.tgz#005b3fdf0ed96e88041330379e0da9a708eb2907" + integrity sha512-zRL0IMM02AUDwghf5LMSSDEz7sBCO2YnNmpg3uWTZj/v1rcG2BmQUvaGU8GhU8BvfMh1k2KIAYZ7Ji9KXPUg7g== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.2.2" + "@babel/types" "^7.2.2" + +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.1.5", "@babel/traverse@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.3.4.tgz#1330aab72234f8dea091b08c4f8b9d05c7119e06" + integrity sha512-TvTHKp6471OYEcE/91uWmhR6PrrYywQntCHSaZ8CM8Vmp+pjAusal4nGB2WCCQd0rvI7nOMKn9GnbcvTUz3/ZQ== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/generator" "^7.3.4" + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-split-export-declaration" "^7.0.0" + "@babel/parser" "^7.3.4" + "@babel/types" "^7.3.4" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.11" + +"@babel/types@^7.0.0", "@babel/types@^7.2.2", "@babel/types@^7.3.0", "@babel/types@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.3.4.tgz#bf482eaeaffb367a28abbf9357a94963235d90ed" + integrity sha512-WEkp8MsLftM7O/ty580wAmZzN1nDmCACc5+jFzUt+GUFNNIi3LdRlueYz0YIlmJhlZx1QYDMZL5vdWCL0fNjFQ== + dependencies: + esutils "^2.0.2" + lodash "^4.17.11" + to-fast-properties "^2.0.0" + +"@optimizely/js-sdk-logging@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-logging/-/js-sdk-logging-0.1.0.tgz#e5950c3d9a708fbd5931a043130469c5df7f64e8" + integrity sha512-Bs2zHvsdNIk2QSg05P6mKIlROHoBIRNStbrVwlePm603CucojKRPlFJG4rt7sFZQOo8xS8I7z1BmE4QI3/ZE9A== + dependencies: + "@optimizely/js-sdk-utils" "^0.1.0" + +"@optimizely/js-sdk-utils@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-utils/-/js-sdk-utils-0.1.0.tgz#e3ac1fef81f11c15774f4743c3fa7c65d9c3352a" + integrity sha512-p7499GgVaX94YmkrwOiEtLgxgjXTPbUQsvETaAil5J7zg1TOA4Wl8ClalLSvCh+AKWkxGdkL4/uM/zfbxPSNNw== + dependencies: + uuid "^3.3.2" + +"@types/jest-diff@*": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" + integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== + +"@types/jest@^24.0.9": + version "24.0.9" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.9.tgz#74ce9cf337f25e189aa18f76ab3d65e8669b55f2" + integrity sha512-k3OOeevcBYLR5pdsOv5g3OP94h3mrJmLPHFEPWgbbVy2tGv0TZ/TlygiC848ogXhK8NL0I5up7YYtwpCp8xCJA== + dependencies: + "@types/jest-diff" "*" + +"@types/node@^11.10.5": + version "11.10.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.5.tgz#fbaca34086bdc118011e1f05c47688d432f2d571" + integrity sha512-DuIRlQbX4K+d5I+GMnv+UfnGh+ist0RdlvOp+JZ7ePJ6KQONCFQv/gKYSU1ZzbVdFSUCKZOltjmpFAGGv5MdYA== + +abab@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" + integrity sha512-sY5AXXVZv4Y1VACTtR11UJCPHHudgY5i26Qj5TypE6DKlIApbwb5uqhXcJ5UUGbvZNRh7EeIoW+LrJumBsKp7w== + +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +acorn-globals@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.0.tgz#e3b6f8da3c1552a95ae627571f7dd6923bb54103" + integrity sha512-hMtHj3s5RnuhvHPowpBYvJVj3rAar82JiDQHvGs1zO0l10ocX/xEdBShNHTJaboucJUsScghp74pH3s7EnHHQw== + dependencies: + acorn "^6.0.1" + acorn-walk "^6.0.1" + +acorn-walk@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.1.1.tgz#d363b66f5fac5f018ff9c3a1e7b6f8e310cc3913" + integrity sha512-OtUw6JUTgxA2QoqqmrmQ7F2NYqiBPi/L2jqHyFtllhOUvXYQXf0Z1CYUinIfyT4bTCGmrA7gX9FvHA81uzCoVw== + +acorn@^5.5.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279" + integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw== + +acorn@^6.0.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== + +ajv@^6.5.5: + version "6.10.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" + integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + +ansi-regex@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" + integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= + +ansi-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" + integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= + +ansi-regex@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.0.0.tgz#70de791edf021404c3fd615aa89118ae0432e5a9" + integrity sha512-iB5Dda8t/UqpPI/IjsejXu5jOGDrzn41wJyljwPH65VCIbk6+1BzFIMJGFwTNrYXT1CrD+B4l19U7awiQ8rk7w== + +ansi-styles@^3.2.0, ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +anymatch@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" + integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== + dependencies: + micromatch "^3.1.4" + normalize-path "^2.1.1" + +append-transform@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab" + integrity sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw== + dependencies: + default-require-extensions "^2.0.0" + +aproba@^1.0.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" + integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== + +are-we-there-yet@~1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" + integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== + dependencies: + delegates "^1.0.0" + readable-stream "^2.0.6" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +arr-diff@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" + integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= + +arr-flatten@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" + integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== + +arr-union@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" + integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= + +array-equal@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" + integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= + +array-unique@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" + integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= + +arrify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" + integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +assign-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" + integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= + +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + +async-limiter@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" + integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== + +async@^2.5.0, async@^2.6.1: + version "2.6.2" + resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381" + integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg== + dependencies: + lodash "^4.17.11" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +atob@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" + integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== + +babel-jest@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.1.0.tgz#441e23ef75ded3bd547e300ac3194cef87b55190" + integrity sha512-MLcagnVrO9ybQGLEfZUqnOzv36iQzU7Bj4elm39vCukumLVSfoX+tRy3/jW7lUKc7XdpRmB/jech6L/UCsSZjw== + dependencies: + babel-plugin-istanbul "^5.1.0" + babel-preset-jest "^24.1.0" + chalk "^2.4.2" + slash "^2.0.0" + +babel-plugin-istanbul@^5.1.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.1.1.tgz#7981590f1956d75d67630ba46f0c22493588c893" + integrity sha512-RNNVv2lsHAXJQsEJ5jonQwrJVWK8AcZpG1oxhnjCUaAjL7xahYLANhPUZbzEQHjKy1NMYUwn+0NPKQc8iSY4xQ== + dependencies: + find-up "^3.0.0" + istanbul-lib-instrument "^3.0.0" + test-exclude "^5.0.0" + +babel-plugin-jest-hoist@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-24.1.0.tgz#dfecc491fb15e2668abbd690a697a8fd1411a7f8" + integrity sha512-gljYrZz8w1b6fJzKcsfKsipSru2DU2DmQ39aB6nV3xQ0DDv3zpIzKGortA5gknrhNnPN8DweaEgrnZdmbGmhnw== + +babel-preset-jest@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.1.0.tgz#83bc564fdcd4903641af65ec63f2f5de6b04132e" + integrity sha512-FfNLDxFWsNX9lUmtwY7NheGlANnagvxq8LZdl5PKnVG3umP+S/g0XbVBfwtA4Ai3Ri/IMkWabBz3Tyk9wdspcw== + dependencies: + "@babel/plugin-syntax-object-rest-spread" "^7.0.0" + babel-plugin-jest-hoist "^24.1.0" + +balanced-match@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" + integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= + +base@^0.11.1: + version "0.11.2" + resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" + integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== + dependencies: + cache-base "^1.0.1" + class-utils "^0.3.5" + component-emitter "^1.2.1" + define-property "^1.0.0" + isobject "^3.0.1" + mixin-deep "^1.2.0" + pascalcase "^0.1.1" + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" + integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== + dependencies: + arr-flatten "^1.1.0" + array-unique "^0.3.2" + extend-shallow "^2.0.1" + fill-range "^4.0.0" + isobject "^3.0.1" + repeat-element "^1.1.2" + snapdragon "^0.8.1" + snapdragon-node "^2.0.1" + split-string "^3.0.2" + to-regex "^3.0.1" + +browser-process-hrtime@^0.1.2: + version "0.1.3" + resolved "https://registry.yarnpkg.com/browser-process-hrtime/-/browser-process-hrtime-0.1.3.tgz#616f00faef1df7ec1b5bf9cfe2bdc3170f26c7b4" + integrity sha512-bRFnI4NnjO6cnyLmOV/7PVoDEMJChlcfN0z4s1YMBY989/SvlfMI1lgCnkFUs53e9gQF+w7qu7XdllSTiSl8Aw== + +browser-resolve@^1.11.3: + version "1.11.3" + resolved "https://registry.yarnpkg.com/browser-resolve/-/browser-resolve-1.11.3.tgz#9b7cbb3d0f510e4cb86bdbd796124d28b5890af6" + integrity sha512-exDi1BYWB/6raKHmDTCicQfTkqwN5fioMFV4j8BsfMU4R2DK/QfZfK7kOVkmWCNANf0snkBzqGqAJBao9gZMdQ== + dependencies: + resolve "1.1.7" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.0.0.tgz#9ac78d3ed5d915804fd87acb158bc797147a1719" + integrity sha1-mseNPtXZFYBP2HrLFYvHlxR6Fxk= + dependencies: + node-int64 "^0.4.0" + +buffer-from@1.x, buffer-from@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" + integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== + +cache-base@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" + integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== + dependencies: + collection-visit "^1.0.0" + component-emitter "^1.2.1" + get-value "^2.0.6" + has-value "^1.0.0" + isobject "^3.0.1" + set-value "^2.0.0" + to-object-path "^0.3.0" + union-value "^1.0.0" + unset-value "^1.0.0" + +callsites@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.0.0.tgz#fb7eb569b72ad7a45812f93fd9430a3e410b3dd3" + integrity sha512-tWnkwu9YEq2uzlBDI4RcLn8jrFvF9AOi8PxDNU3hZZjJcjkcRAq3vCI+vZcg1SuxISDYe86k9VZFwAxDiJGoAw== + +camelcase@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" + integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= + +camelcase@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.1.0.tgz#29e83b9cfaf7ad478f401a187ae089cf83c257ea" + integrity sha512-WP9f9OBL/TAbwOFBJL79FoS9UKUmnp82RWnhlwTgrAJeMq7lytHhe0Jzc6/P7Zq0+2oviXJuPlvkZalWUug9gg== + +capture-exit@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-1.2.0.tgz#1c5fcc489fd0ab00d4f1ac7ae1072e3173fbab6f" + integrity sha1-HF/MSJ/QqwDU8ax64QcuMXP7q28= + dependencies: + rsvp "^3.3.3" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chownr@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" + integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g== + +ci-info@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" + integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== + +class-utils@^0.3.5: + version "0.3.6" + resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" + integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== + dependencies: + arr-union "^3.1.0" + define-property "^0.2.5" + isobject "^3.0.0" + static-extend "^0.1.1" + +cliui@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" + integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== + dependencies: + string-width "^2.1.1" + strip-ansi "^4.0.0" + wrap-ansi "^2.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ= + +code-point-at@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" + integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= + +collection-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" + integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= + dependencies: + map-visit "^1.0.0" + object-visit "^1.0.0" + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + integrity sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w== + dependencies: + delayed-stream "~1.0.0" + +commander@~2.17.1: + version "2.17.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" + integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== + +compare-versions@^3.2.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.4.0.tgz#e0747df5c9cb7f054d6d3dc3e1dbc444f9e92b26" + integrity sha512-tK69D7oNXXqUW3ZNo/z7NXTEz22TCF0pTE+YF9cxvaAM9XnkLo1fV621xCLrRR6aevJlKxExkss0vWqUCUpqdg== + +component-emitter@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" + integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= + +console-control-strings@^1.0.0, console-control-strings@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + +convert-source-map@^1.1.0, convert-source-map@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20" + integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A== + dependencies: + safe-buffer "~5.1.1" + +copy-descriptor@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" + integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= + +core-util-is@1.0.2, core-util-is@~1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +cross-spawn@^6.0.0: + version "6.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" + integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== + dependencies: + nice-try "^1.0.4" + path-key "^2.0.1" + semver "^5.5.0" + shebang-command "^1.2.0" + which "^1.2.9" + +cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": + version "0.3.6" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.6.tgz#f85206cee04efa841f3c5982a74ba96ab20d65ad" + integrity sha512-DtUeseGk9/GBW0hl0vVPpU22iHL6YB5BUX7ml1hB+GMpo0NX5G4voX3kdWiMSEguFtcW3Vh3djqNF4aIe6ne0A== + +cssstyle@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.2.1.tgz#3aceb2759eaf514ac1a21628d723d6043a819495" + integrity sha512-7DYm8qe+gPx/h77QlCyFmX80+fGaE/6A/Ekl0zaszYOubvySO2saYFdQ78P29D0UsULxFKCetDGNaNRUdSF+2A== + dependencies: + cssom "0.3.x" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +data-urls@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-1.1.0.tgz#15ee0582baa5e22bb59c77140da8f9c76963bbfe" + integrity sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ== + dependencies: + abab "^2.0.0" + whatwg-mimetype "^2.2.0" + whatwg-url "^7.0.0" + +debug@^2.1.2, debug@^2.2.0, debug@^2.3.3: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@^4.1.0, debug@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= + +decode-uri-component@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" + integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= + +deep-extend@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@~0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" + integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + +default-require-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-2.0.0.tgz#f5f8fbb18a7d6d50b21f641f649ebb522cfe24f7" + integrity sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc= + dependencies: + strip-bom "^3.0.0" + +define-properties@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" + integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + dependencies: + object-keys "^1.0.12" + +define-property@^0.2.5: + version "0.2.5" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" + integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= + dependencies: + is-descriptor "^0.1.0" + +define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" + integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= + dependencies: + is-descriptor "^1.0.0" + +define-property@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" + integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== + dependencies: + is-descriptor "^1.0.2" + isobject "^3.0.1" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +delegates@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" + integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= + +detect-libc@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= + +detect-newline@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" + integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I= + +diff-sequences@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.0.0.tgz#cdf8e27ed20d8b8d3caccb4e0c0d8fe31a173013" + integrity sha512-46OkIuVGBBnrC0soO/4LHu5LHGHx0uhP65OVz8XOrAJpqiCB2aVIuESvjI1F9oqebuvY8lekS1pt6TN7vt7qsw== + +domexception@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" + integrity sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug== + dependencies: + webidl-conversions "^4.0.2" + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +end-of-stream@^1.1.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" + integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== + dependencies: + once "^1.4.0" + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +es-abstract@^1.5.1: + version "1.13.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" + integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg== + dependencies: + es-to-primitive "^1.2.0" + function-bind "^1.1.1" + has "^1.0.3" + is-callable "^1.1.4" + is-regex "^1.0.4" + object-keys "^1.0.12" + +es-to-primitive@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377" + integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg== + dependencies: + is-callable "^1.1.4" + is-date-object "^1.0.1" + is-symbol "^1.0.2" + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= + +escodegen@^1.9.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.11.1.tgz#c485ff8d6b4cdb89e27f4a856e91f118401ca510" + integrity sha512-JwiqFD9KdGVVpeuRa68yU3zZnBEOcPs0nKW7wZzXky8Z7tffdYUHbe11bPCV5jYlK6DVdKLWLm0f5I/QlL0Kmw== + dependencies: + esprima "^3.1.3" + estraverse "^4.2.0" + esutils "^2.0.2" + optionator "^0.8.1" + optionalDependencies: + source-map "~0.6.1" + +esprima@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633" + integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM= + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +estraverse@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" + integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= + +esutils@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" + integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= + +exec-sh@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" + integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== + dependencies: + merge "^1.2.0" + +execa@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" + integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== + dependencies: + cross-spawn "^6.0.0" + get-stream "^4.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= + +expand-brackets@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" + integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= + dependencies: + debug "^2.3.3" + define-property "^0.2.5" + extend-shallow "^2.0.1" + posix-character-classes "^0.1.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +expect@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-24.1.0.tgz#88e73301c4c785cde5f16da130ab407bdaf8c0f2" + integrity sha512-lVcAPhaYkQcIyMS+F8RVwzbm1jro20IG8OkvxQ6f1JfqhVZyyudCwYogQ7wnktlf14iF3ii7ArIUO/mqvrW9Gw== + dependencies: + ansi-styles "^3.2.0" + jest-get-type "^24.0.0" + jest-matcher-utils "^24.0.0" + jest-message-util "^24.0.0" + jest-regex-util "^24.0.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= + dependencies: + is-extendable "^0.1.0" + +extend-shallow@^3.0.0, extend-shallow@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" + integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= + dependencies: + assign-symbols "^1.0.0" + is-extendable "^1.0.1" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extglob@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" + integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== + dependencies: + array-unique "^0.3.2" + define-property "^1.0.0" + expand-brackets "^2.1.4" + extend-shallow "^2.0.1" + fragment-cache "^0.2.1" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= + +fast-levenshtein@~2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= + +fb-watchman@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58" + integrity sha1-VOmr99+i8mzZsWNsWIwa/AXeXVg= + dependencies: + bser "^2.0.0" + +fileset@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" + integrity sha1-jnVIqW08wjJ+5eZ0FocjozO7oqA= + dependencies: + glob "^7.0.3" + minimatch "^3.0.3" + +fill-range@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" + integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= + dependencies: + extend-shallow "^2.0.1" + is-number "^3.0.0" + repeat-string "^1.6.1" + to-regex-range "^2.1.0" + +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +fragment-cache@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" + integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= + dependencies: + map-cache "^0.2.2" + +fs-minipass@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.5.tgz#06c277218454ec288df77ada54a03b8702aacb9d" + integrity sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ== + dependencies: + minipass "^2.2.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + +fsevents@^1.2.3: + version "1.2.7" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" + integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + dependencies: + nan "^2.9.2" + node-pre-gyp "^0.10.0" + +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== + +gauge@~2.7.3: + version "2.7.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" + integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= + dependencies: + aproba "^1.0.3" + console-control-strings "^1.0.0" + has-unicode "^2.0.0" + object-assign "^4.1.0" + signal-exit "^3.0.0" + string-width "^1.0.1" + strip-ansi "^3.0.1" + wide-align "^1.1.0" + +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + +get-stream@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" + integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== + dependencies: + pump "^3.0.0" + +get-value@^2.0.3, get-value@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" + integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.11.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.11.0.tgz#dcf93757fa2de5486fbeed7118538adf789e9c2e" + integrity sha512-WHq43gS+6ufNOEqlrDBxVEbb8ntfXrfAUU2ZOpCxrBdGKW3gyv8mCxAfIBD0DroPKGrJ2eSsXsLtY9MPntsyTw== + +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2: + version "4.1.15" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" + integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== + +growly@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" + integrity sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE= + +handlebars@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.1.0.tgz#0d6a6f34ff1f63cecec8423aa4169827bf787c3a" + integrity sha512-l2jRuU1NAWK6AW5qqcTATWQJvNPEwkM7NEKSiv/gqOsoSQbVoWyqVEY5GS+XPQ88zLNmqASRpzfdm8d79hJS+w== + dependencies: + async "^2.5.0" + optimist "^0.6.1" + source-map "^0.6.1" + optionalDependencies: + uglify-js "^3.1.4" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= + +has-symbols@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" + integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= + +has-unicode@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= + +has-value@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" + integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= + dependencies: + get-value "^2.0.3" + has-values "^0.1.4" + isobject "^2.0.0" + +has-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" + integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= + dependencies: + get-value "^2.0.6" + has-values "^1.0.0" + isobject "^3.0.0" + +has-values@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" + integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= + +has-values@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" + integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= + dependencies: + is-number "^3.0.0" + kind-of "^4.0.0" + +has@^1.0.1, has@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" + integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== + dependencies: + function-bind "^1.1.1" + +hosted-git-info@^2.1.4: + version "2.7.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" + integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== + +html-encoding-sniffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz#e70d84b94da53aa375e11fe3a351be6642ca46f8" + integrity sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw== + dependencies: + whatwg-encoding "^1.0.1" + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +iconv-lite@0.4.24, iconv-lite@^0.4.4: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +ignore-walk@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" + integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== + dependencies: + minimatch "^3.0.4" + +import-local@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d" + integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ== + dependencies: + pkg-dir "^3.0.0" + resolve-cwd "^2.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@~2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" + integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= + +ini@~1.3.0: + version "1.3.5" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" + integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + +invert-kv@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" + integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== + +is-accessor-descriptor@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" + integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= + dependencies: + kind-of "^3.0.2" + +is-accessor-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" + integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== + dependencies: + kind-of "^6.0.0" + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-callable@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" + integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== + +is-ci@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" + integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== + dependencies: + ci-info "^2.0.0" + +is-data-descriptor@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" + integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= + dependencies: + kind-of "^3.0.2" + +is-data-descriptor@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" + integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== + dependencies: + kind-of "^6.0.0" + +is-date-object@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" + integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= + +is-descriptor@^0.1.0: + version "0.1.6" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" + integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== + dependencies: + is-accessor-descriptor "^0.1.6" + is-data-descriptor "^0.1.4" + kind-of "^5.0.0" + +is-descriptor@^1.0.0, is-descriptor@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" + integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== + dependencies: + is-accessor-descriptor "^1.0.0" + is-data-descriptor "^1.0.0" + kind-of "^6.0.2" + +is-extendable@^0.1.0, is-extendable@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-fullwidth-code-point@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" + integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= + dependencies: + number-is-nan "^1.0.0" + +is-fullwidth-code-point@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" + integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= + +is-generator-fn@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.0.0.tgz#038c31b774709641bda678b1f06a4e3227c10b3e" + integrity sha512-elzyIdM7iKoFHzcrndIqjYomImhxrFRnGP3galODoII4TB9gI7mZ+FnlLQmmjf27SxHS2gKEeyhX5/+YRS6H9g== + +is-number@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" + integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= + dependencies: + kind-of "^3.0.2" + +is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-regex@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" + integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= + dependencies: + has "^1.0.1" + +is-stream@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-symbol@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" + integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== + dependencies: + has-symbols "^1.0.0" + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +is-windows@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" + integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== + +is-wsl@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" + integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= + dependencies: + isarray "1.0.0" + +isobject@^3.0.0, isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +istanbul-api@^2.0.8: + version "2.1.1" + resolved "https://registry.yarnpkg.com/istanbul-api/-/istanbul-api-2.1.1.tgz#194b773f6d9cbc99a9258446848b0f988951c4d0" + integrity sha512-kVmYrehiwyeBAk/wE71tW6emzLiHGjYIiDrc8sfyty4F8M02/lrgXSm+R1kXysmF20zArvmZXjlE/mg24TVPJw== + dependencies: + async "^2.6.1" + compare-versions "^3.2.1" + fileset "^2.0.3" + istanbul-lib-coverage "^2.0.3" + istanbul-lib-hook "^2.0.3" + istanbul-lib-instrument "^3.1.0" + istanbul-lib-report "^2.0.4" + istanbul-lib-source-maps "^3.0.2" + istanbul-reports "^2.1.1" + js-yaml "^3.12.0" + make-dir "^1.3.0" + minimatch "^3.0.4" + once "^1.4.0" + +istanbul-lib-coverage@^2.0.2, istanbul-lib-coverage@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#0b891e5ad42312c2b9488554f603795f9a2211ba" + integrity sha512-dKWuzRGCs4G+67VfW9pBFFz2Jpi4vSp/k7zBcJ888ofV5Mi1g5CUML5GvMvV6u9Cjybftu+E8Cgp+k0dI1E5lw== + +istanbul-lib-hook@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-2.0.3.tgz#e0e581e461c611be5d0e5ef31c5f0109759916fb" + integrity sha512-CLmEqwEhuCYtGcpNVJjLV1DQyVnIqavMLFHV/DP+np/g3qvdxu3gsPqYoJMXm15sN84xOlckFB3VNvRbf5yEgA== + dependencies: + append-transform "^1.0.0" + +istanbul-lib-instrument@^3.0.0, istanbul-lib-instrument@^3.0.1, istanbul-lib-instrument@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-3.1.0.tgz#a2b5484a7d445f1f311e93190813fa56dfb62971" + integrity sha512-ooVllVGT38HIk8MxDj/OIHXSYvH+1tq/Vb38s8ixt9GoJadXska4WkGY+0wkmtYCZNYtaARniH/DixUGGLZ0uA== + dependencies: + "@babel/generator" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/template" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + istanbul-lib-coverage "^2.0.3" + semver "^5.5.0" + +istanbul-lib-report@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-2.0.4.tgz#bfd324ee0c04f59119cb4f07dab157d09f24d7e4" + integrity sha512-sOiLZLAWpA0+3b5w5/dq0cjm2rrNdAfHWaGhmn7XEFW6X++IV9Ohn+pnELAl9K3rfpaeBfbmH9JU5sejacdLeA== + dependencies: + istanbul-lib-coverage "^2.0.3" + make-dir "^1.3.0" + supports-color "^6.0.0" + +istanbul-lib-source-maps@^3.0.1, istanbul-lib-source-maps@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.2.tgz#f1e817229a9146e8424a28e5d69ba220fda34156" + integrity sha512-JX4v0CiKTGp9fZPmoxpu9YEkPbEqCqBbO3403VabKjH+NRXo72HafD5UgnjTEqHL2SAjaZK1XDuDOkn6I5QVfQ== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^2.0.3" + make-dir "^1.3.0" + rimraf "^2.6.2" + source-map "^0.6.1" + +istanbul-reports@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-2.1.1.tgz#72ef16b4ecb9a4a7bd0e2001e00f95d1eec8afa9" + integrity sha512-FzNahnidyEPBCI0HcufJoSEoKykesRlFcSzQqjH9x0+LC8tnnE/p/90PBLu8iZTxr8yYZNyTtiAujUqyN+CIxw== + dependencies: + handlebars "^4.1.0" + +jest-changed-files@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.0.0.tgz#c02c09a8cc9ca93f513166bc773741bd39898ff7" + integrity sha512-nnuU510R9U+UX0WNb5XFEcsrMqriSiRLeO9KWDFgPrpToaQm60prfQYpxsXigdClpvNot5bekDY440x9dNGnsQ== + dependencies: + execa "^1.0.0" + throat "^4.0.0" + +jest-cli@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-24.1.0.tgz#f7cc98995f36e7210cce3cbb12974cbf60940843" + integrity sha512-U/iyWPwOI0T1CIxVLtk/2uviOTJ/OiSWJSe8qt6X1VkbbgP+nrtLJlmT9lPBe4lK78VNFJtrJ7pttcNv/s7yCw== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + exit "^0.1.2" + glob "^7.1.2" + graceful-fs "^4.1.15" + import-local "^2.0.0" + is-ci "^2.0.0" + istanbul-api "^2.0.8" + istanbul-lib-coverage "^2.0.2" + istanbul-lib-instrument "^3.0.1" + istanbul-lib-source-maps "^3.0.1" + jest-changed-files "^24.0.0" + jest-config "^24.1.0" + jest-environment-jsdom "^24.0.0" + jest-get-type "^24.0.0" + jest-haste-map "^24.0.0" + jest-message-util "^24.0.0" + jest-regex-util "^24.0.0" + jest-resolve-dependencies "^24.1.0" + jest-runner "^24.1.0" + jest-runtime "^24.1.0" + jest-snapshot "^24.1.0" + jest-util "^24.0.0" + jest-validate "^24.0.0" + jest-watcher "^24.0.0" + jest-worker "^24.0.0" + micromatch "^3.1.10" + node-notifier "^5.2.1" + p-each-series "^1.0.0" + pirates "^4.0.0" + prompts "^2.0.1" + realpath-native "^1.0.0" + rimraf "^2.5.4" + slash "^2.0.0" + string-length "^2.0.0" + strip-ansi "^5.0.0" + which "^1.2.12" + yargs "^12.0.2" + +jest-config@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-24.1.0.tgz#6ea6881cfdd299bc86cc144ee36d937c97c3850c" + integrity sha512-FbbRzRqtFC6eGjG5VwsbW4E5dW3zqJKLWYiZWhB0/4E5fgsMw8GODLbGSrY5t17kKOtCWb/Z7nsIThRoDpuVyg== + dependencies: + "@babel/core" "^7.1.0" + babel-jest "^24.1.0" + chalk "^2.0.1" + glob "^7.1.1" + jest-environment-jsdom "^24.0.0" + jest-environment-node "^24.0.0" + jest-get-type "^24.0.0" + jest-jasmine2 "^24.1.0" + jest-regex-util "^24.0.0" + jest-resolve "^24.1.0" + jest-util "^24.0.0" + jest-validate "^24.0.0" + micromatch "^3.1.10" + pretty-format "^24.0.0" + realpath-native "^1.0.2" + +jest-diff@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-24.0.0.tgz#a3e5f573dbac482f7d9513ac9cfa21644d3d6b34" + integrity sha512-XY5wMpRaTsuMoU+1/B2zQSKQ9RdE9gsLkGydx3nvApeyPijLA8GtEvIcPwISRCer+VDf9W1mStTYYq6fPt8ryA== + dependencies: + chalk "^2.0.1" + diff-sequences "^24.0.0" + jest-get-type "^24.0.0" + pretty-format "^24.0.0" + +jest-docblock@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-24.0.0.tgz#54d77a188743e37f62181a91a01eb9222289f94e" + integrity sha512-KfAKZ4SN7CFOZpWg4i7g7MSlY0M+mq7K0aMqENaG2vHuhC9fc3vkpU/iNN9sOus7v3h3Y48uEjqz3+Gdn2iptA== + dependencies: + detect-newline "^2.1.0" + +jest-each@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-24.0.0.tgz#10987a06b21c7ffbfb7706c89d24c52ed864be55" + integrity sha512-gFcbY4Cu55yxExXMkjrnLXov3bWO3dbPAW7HXb31h/DNWdNc/6X8MtxGff8nh3/MjkF9DpVqnj0KsPKuPK0cpA== + dependencies: + chalk "^2.0.1" + jest-get-type "^24.0.0" + jest-util "^24.0.0" + pretty-format "^24.0.0" + +jest-environment-jsdom@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-24.0.0.tgz#5affa0654d6e44cd798003daa1a8701dbd6e4d11" + integrity sha512-1YNp7xtxajTRaxbylDc2pWvFnfDTH5BJJGyVzyGAKNt/lEULohwEV9zFqTgG4bXRcq7xzdd+sGFws+LxThXXOw== + dependencies: + jest-mock "^24.0.0" + jest-util "^24.0.0" + jsdom "^11.5.1" + +jest-environment-node@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-24.0.0.tgz#330948980656ed8773ce2e04eb597ed91e3c7190" + integrity sha512-62fOFcaEdU0VLaq8JL90TqwI7hLn0cOKOl8vY2n477vRkCJRojiRRtJVRzzCcgFvs6gqU97DNqX5R0BrBP6Rxg== + dependencies: + jest-mock "^24.0.0" + jest-util "^24.0.0" + +jest-get-type@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.0.0.tgz#36e72930b78e33da59a4f63d44d332188278940b" + integrity sha512-z6/Eyf6s9ZDGz7eOvl+fzpuJmN9i0KyTt1no37/dHu8galssxz5ZEgnc1KaV8R31q1khxyhB4ui/X5ZjjPk77w== + +jest-haste-map@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.0.0.tgz#e9ef51b2c9257384b4d6beb83bd48c65b37b5e6e" + integrity sha512-CcViJyUo41IQqttLxXVdI41YErkzBKbE6cS6dRAploCeutePYfUimWd3C9rQEWhX0YBOQzvNsC0O9nYxK2nnxQ== + dependencies: + fb-watchman "^2.0.0" + graceful-fs "^4.1.15" + invariant "^2.2.4" + jest-serializer "^24.0.0" + jest-util "^24.0.0" + jest-worker "^24.0.0" + micromatch "^3.1.10" + sane "^3.0.0" + +jest-jasmine2@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-24.1.0.tgz#8377324b967037c440f0a549ee0bbd9912055db6" + integrity sha512-H+o76SdSNyCh9fM5K8upK45YTo/DiFx5w2YAzblQebSQmukDcoVBVeXynyr7DDnxh+0NTHYRCLwJVf3tC518wg== + dependencies: + "@babel/traverse" "^7.1.0" + chalk "^2.0.1" + co "^4.6.0" + expect "^24.1.0" + is-generator-fn "^2.0.0" + jest-each "^24.0.0" + jest-matcher-utils "^24.0.0" + jest-message-util "^24.0.0" + jest-snapshot "^24.1.0" + jest-util "^24.0.0" + pretty-format "^24.0.0" + throat "^4.0.0" + +jest-leak-detector@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-24.0.0.tgz#78280119fd05ee98317daee62cddb3aa537a31c6" + integrity sha512-ZYHJYFeibxfsDSKowjDP332pStuiFT2xfc5R67Rjm/l+HFJWJgNIOCOlQGeXLCtyUn3A23+VVDdiCcnB6dTTrg== + dependencies: + pretty-format "^24.0.0" + +jest-matcher-utils@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-24.0.0.tgz#fc9c41cfc49b2c3ec14e576f53d519c37729d579" + integrity sha512-LQTDmO+aWRz1Tf9HJg+HlPHhDh1E1c65kVwRFo5mwCVp5aQDzlkz4+vCvXhOKFjitV2f0kMdHxnODrXVoi+rlA== + dependencies: + chalk "^2.0.1" + jest-diff "^24.0.0" + jest-get-type "^24.0.0" + pretty-format "^24.0.0" + +jest-message-util@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.0.0.tgz#a07a141433b2c992dbaec68d4cbfe470ba289619" + integrity sha512-J9ROJIwz/IeC+eV1XSwnRK4oAwPuhmxEyYx1+K5UI+pIYwFZDSrfZaiWTdq0d2xYFw4Xiu+0KQWsdsQpgJMf3Q== + dependencies: + "@babel/code-frame" "^7.0.0" + chalk "^2.0.1" + micromatch "^3.1.10" + slash "^2.0.0" + stack-utils "^1.0.1" + +jest-mock@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.0.0.tgz#9a4b53e01d66a0e780f7d857462d063e024c617d" + integrity sha512-sQp0Hu5fcf5NZEh1U9eIW2qD0BwJZjb63Yqd98PQJFvf/zzUTBoUAwv/Dc/HFeNHIw1f3hl/48vNn+j3STaI7A== + +jest-regex-util@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-24.0.0.tgz#4feee8ec4a358f5bee0a654e94eb26163cb9089a" + integrity sha512-Jv/uOTCuC+PY7WpJl2mpoI+WbY2ut73qwwO9ByJJNwOCwr1qWhEW2Lyi2S9ZewUdJqeVpEBisdEVZSI+Zxo58Q== + +jest-resolve-dependencies@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-24.1.0.tgz#78f738a2ec59ff4d00751d9da56f176e3f589f6c" + integrity sha512-2VwPsjd3kRPu7qe2cpytAgowCObk5AKeizfXuuiwgm1a9sijJDZe8Kh1sFj6FKvSaNEfCPlBVkZEJa2482m/Uw== + dependencies: + jest-regex-util "^24.0.0" + jest-snapshot "^24.1.0" + +jest-resolve@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-24.1.0.tgz#42ff0169b0ea47bfdbd0c52a0067ca7d022c7688" + integrity sha512-TPiAIVp3TG6zAxH28u/6eogbwrvZjBMWroSLBDkwkHKrqxB/RIdwkWDye4uqPlZIXWIaHtifY3L0/eO5Z0f2wg== + dependencies: + browser-resolve "^1.11.3" + chalk "^2.0.1" + realpath-native "^1.0.0" + +jest-runner@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-24.1.0.tgz#3686a2bb89ce62800da23d7fdc3da2c32792943b" + integrity sha512-CDGOkT3AIFl16BLL/OdbtYgYvbAprwJ+ExKuLZmGSCSldwsuU2dEGauqkpvd9nphVdAnJUcP12e/EIlnTX0QXg== + dependencies: + chalk "^2.4.2" + exit "^0.1.2" + graceful-fs "^4.1.15" + jest-config "^24.1.0" + jest-docblock "^24.0.0" + jest-haste-map "^24.0.0" + jest-jasmine2 "^24.1.0" + jest-leak-detector "^24.0.0" + jest-message-util "^24.0.0" + jest-runtime "^24.1.0" + jest-util "^24.0.0" + jest-worker "^24.0.0" + source-map-support "^0.5.6" + throat "^4.0.0" + +jest-runtime@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-24.1.0.tgz#7c157a2e776609e8cf552f956a5a19ec9c985214" + integrity sha512-59/BY6OCuTXxGeDhEMU7+N33dpMQyXq7MLK07cNSIY/QYt2QZgJ7Tjx+rykBI0skAoigFl0A5tmT8UdwX92YuQ== + dependencies: + "@babel/core" "^7.1.0" + babel-plugin-istanbul "^5.1.0" + chalk "^2.0.1" + convert-source-map "^1.4.0" + exit "^0.1.2" + fast-json-stable-stringify "^2.0.0" + glob "^7.1.3" + graceful-fs "^4.1.15" + jest-config "^24.1.0" + jest-haste-map "^24.0.0" + jest-message-util "^24.0.0" + jest-regex-util "^24.0.0" + jest-resolve "^24.1.0" + jest-snapshot "^24.1.0" + jest-util "^24.0.0" + jest-validate "^24.0.0" + micromatch "^3.1.10" + realpath-native "^1.0.0" + slash "^2.0.0" + strip-bom "^3.0.0" + write-file-atomic "2.4.1" + yargs "^12.0.2" + +jest-serializer@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-24.0.0.tgz#522c44a332cdd194d8c0531eb06a1ee5afb4256b" + integrity sha512-9FKxQyrFgHtx3ozU+1a8v938ILBE7S8Ko3uiAVjT8Yfi2o91j/fj81jacCQZ/Ihjiff/VsUCXVgQ+iF1XdImOw== + +jest-snapshot@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-24.1.0.tgz#85e22f810357aa5994ab61f236617dc2205f2f5b" + integrity sha512-th6TDfFqEmXvuViacU1ikD7xFb7lQsPn2rJl7OEmnfIVpnrx3QNY2t3PE88meeg0u/mQ0nkyvmC05PBqO4USFA== + dependencies: + "@babel/types" "^7.0.0" + chalk "^2.0.1" + jest-diff "^24.0.0" + jest-matcher-utils "^24.0.0" + jest-message-util "^24.0.0" + jest-resolve "^24.1.0" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + pretty-format "^24.0.0" + semver "^5.5.0" + +jest-util@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-24.0.0.tgz#fd38fcafd6dedbd0af2944d7a227c0d91b68f7d6" + integrity sha512-QxsALc4wguYS7cfjdQSOr5HTkmjzkHgmZvIDkcmPfl1ib8PNV8QUWLwbKefCudWS0PRKioV+VbQ0oCUPC691fQ== + dependencies: + callsites "^3.0.0" + chalk "^2.0.1" + graceful-fs "^4.1.15" + is-ci "^2.0.0" + jest-message-util "^24.0.0" + mkdirp "^0.5.1" + slash "^2.0.0" + source-map "^0.6.0" + +jest-validate@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-24.0.0.tgz#aa8571a46983a6538328fef20406b4a496b6c020" + integrity sha512-vMrKrTOP4BBFIeOWsjpsDgVXATxCspC9S1gqvbJ3Tnn/b9ACsJmteYeVx9830UMV28Cob1RX55x96Qq3Tfad4g== + dependencies: + camelcase "^5.0.0" + chalk "^2.0.1" + jest-get-type "^24.0.0" + leven "^2.1.0" + pretty-format "^24.0.0" + +jest-watcher@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-24.0.0.tgz#20d44244d10b0b7312410aefd256c1c1eef68890" + integrity sha512-GxkW2QrZ4YxmW1GUWER05McjVDunBlKMFfExu+VsGmXJmpej1saTEKvONdx5RJBlVdpPI5x6E3+EDQSIGgl53g== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.1" + jest-util "^24.0.0" + string-length "^2.0.0" + +jest-worker@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.0.0.tgz#3d3483b077bf04f412f47654a27bba7e947f8b6d" + integrity sha512-s64/OThpfQvoCeHG963MiEZOAAxu8kHsaL/rCMF7lpdzo7vgF0CtPml9hfguOMgykgH/eOm4jFP4ibfHLruytg== + dependencies: + merge-stream "^1.0.1" + supports-color "^6.1.0" + +jest@^24.1.0: + version "24.1.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-24.1.0.tgz#b1e1135caefcf2397950ecf7f90e395fde866fd2" + integrity sha512-+q91L65kypqklvlRFfXfdzUKyngQLOcwGhXQaLmVHv+d09LkNXuBuGxlofTFW42XMzu3giIcChchTsCNUjQ78A== + dependencies: + import-local "^2.0.0" + jest-cli "^24.1.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.12.0: + version "3.12.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.12.2.tgz#ef1d067c5a9d9cb65bd72f285b5d8105c77f14fc" + integrity sha512-QHn/Lh/7HhZ/Twc7vJYQTkjuCa0kaCcDcjK5Zlk2rvnUpy7DxMJ23+Jc2dcyvltwQVg1nygAVlB2oRDFHoRS5Q== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +jsdom@^11.5.1: + version "11.12.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8" + integrity sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw== + dependencies: + abab "^2.0.0" + acorn "^5.5.3" + acorn-globals "^4.1.0" + array-equal "^1.0.0" + cssom ">= 0.3.2 < 0.4.0" + cssstyle "^1.0.0" + data-urls "^1.0.0" + domexception "^1.0.1" + escodegen "^1.9.1" + html-encoding-sniffer "^1.0.2" + left-pad "^1.3.0" + nwsapi "^2.0.7" + parse5 "4.0.0" + pn "^1.1.0" + request "^2.87.0" + request-promise-native "^1.0.5" + sax "^1.2.4" + symbol-tree "^3.2.2" + tough-cookie "^2.3.4" + w3c-hr-time "^1.0.1" + webidl-conversions "^4.0.2" + whatwg-encoding "^1.0.3" + whatwg-mimetype "^2.1.0" + whatwg-url "^6.4.1" + ws "^5.2.0" + xml-name-validator "^3.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-parse-better-errors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" + integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +json5@2.x, json5@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.0.tgz#e7a0c62c48285c628d20a10b85c89bb807c32850" + integrity sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ== + dependencies: + minimist "^1.2.0" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= + dependencies: + is-buffer "^1.1.5" + +kind-of@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" + integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= + dependencies: + is-buffer "^1.1.5" + +kind-of@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" + integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== + +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" + integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== + +kleur@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.2.tgz#83c7ec858a41098b613d5998a7b653962b504f68" + integrity sha512-3h7B2WRT5LNXOtQiAaWonilegHcPSf9nLVXlSTci8lu1dZUuui61+EsPEZqSVxY7rXYmB2DVKMQILxaO5WL61Q== + +lcid@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" + integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== + dependencies: + invert-kv "^2.0.0" + +left-pad@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" + integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA== + +leven@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580" + integrity sha1-wuep93IJTe6dNCAq6KzORoeHVYA= + +levn@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" + integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= + dependencies: + prelude-ls "~1.1.2" + type-check "~0.3.2" + +load-json-file@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" + integrity sha1-L19Fq5HjMhYjT9U62rZo607AmTs= + dependencies: + graceful-fs "^4.1.2" + parse-json "^4.0.0" + pify "^3.0.0" + strip-bom "^3.0.0" + +locate-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" + integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== + dependencies: + p-locate "^3.0.0" + path-exists "^3.0.0" + +lodash.sortby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" + integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= + +lodash@^4.17.11: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" + integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== + +loose-envify@^1.0.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +make-dir@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" + integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ== + dependencies: + pify "^3.0.0" + +make-error@1.x: + version "1.3.5" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8" + integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g== + +makeerror@1.0.x: + version "1.0.11" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" + integrity sha1-4BpckQnyr3lmDk6LlYd5AYT1qWw= + dependencies: + tmpl "1.0.x" + +map-age-cleaner@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" + integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + dependencies: + p-defer "^1.0.0" + +map-cache@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" + integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= + +map-visit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" + integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= + dependencies: + object-visit "^1.0.0" + +mem@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/mem/-/mem-4.1.0.tgz#aeb9be2d21f47e78af29e4ac5978e8afa2ca5b8a" + integrity sha512-I5u6Q1x7wxO0kdOpYBB28xueHADYps5uty/zg936CiG8NTe5sJL8EjrCuLneuDW3PlMdZBGDIn8BirEVdovZvg== + dependencies: + map-age-cleaner "^0.1.1" + mimic-fn "^1.0.0" + p-is-promise "^2.0.0" + +merge-stream@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1" + integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE= + dependencies: + readable-stream "^2.0.1" + +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + +micromatch@^3.1.10, micromatch@^3.1.4: + version "3.1.10" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" + integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + braces "^2.3.1" + define-property "^2.0.2" + extend-shallow "^3.0.2" + extglob "^2.0.4" + fragment-cache "^0.2.1" + kind-of "^6.0.2" + nanomatch "^1.2.9" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.2" + +mime-db@~1.38.0: + version "1.38.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.38.0.tgz#1a2aab16da9eb167b49c6e4df2d9c68d63d8e2ad" + integrity sha512-bqVioMFFzc2awcdJZIzR3HjZFX20QhilVS7hytkKrv7xFAn8bM1gzc/FOX2awLISvWe0PV8ptFKcon+wZ5qYkg== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.22" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.22.tgz#fe6b355a190926ab7698c9a0556a11199b2199bd" + integrity sha512-aGl6TZGnhm/li6F7yx82bJiBZwgiEa4Hf6CNr8YO+r5UHr53tSTYZb102zyU50DOWWKeOv0uQLRL0/9EiKWCog== + dependencies: + mime-db "~1.38.0" + +mimic-fn@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" + integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== + +minimatch@^3.0.3, minimatch@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" + integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== + dependencies: + brace-expansion "^1.1.7" + +minimist@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" + integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= + +minimist@^1.1.1, minimist@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" + integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= + +minimist@~0.0.1: + version "0.0.10" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" + integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= + +minipass@^2.2.1, minipass@^2.3.4: + version "2.3.5" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" + integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== + dependencies: + safe-buffer "^5.1.2" + yallist "^3.0.0" + +minizlib@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" + integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== + dependencies: + minipass "^2.2.1" + +mixin-deep@^1.2.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" + integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +mkdirp@0.x, mkdirp@^0.5.0, mkdirp@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" + integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= + dependencies: + minimist "0.0.8" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= + +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== + +nan@^2.9.2: + version "2.12.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552" + integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw== + +nanomatch@^1.2.9: + version "1.2.13" + resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" + integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== + dependencies: + arr-diff "^4.0.0" + array-unique "^0.3.2" + define-property "^2.0.2" + extend-shallow "^3.0.2" + fragment-cache "^0.2.1" + is-windows "^1.0.2" + kind-of "^6.0.2" + object.pick "^1.3.0" + regex-not "^1.0.0" + snapdragon "^0.8.1" + to-regex "^3.0.1" + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= + +needle@^2.2.1: + version "2.2.4" + resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" + integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== + dependencies: + debug "^2.1.2" + iconv-lite "^0.4.4" + sax "^1.2.4" + +nice-try@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" + integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= + +node-modules-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" + integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= + +node-notifier@^5.2.1: + version "5.4.0" + resolved "https://registry.yarnpkg.com/node-notifier/-/node-notifier-5.4.0.tgz#7b455fdce9f7de0c63538297354f3db468426e6a" + integrity sha512-SUDEb+o71XR5lXSTyivXd9J7fCloE3SyP4lSgt3lU2oSANiox+SxlNRGPjDKrwU1YN3ix2KN/VGGCg0t01rttQ== + dependencies: + growly "^1.3.0" + is-wsl "^1.1.0" + semver "^5.5.0" + shellwords "^0.1.1" + which "^1.3.0" + +node-pre-gyp@^0.10.0: + version "0.10.3" + resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" + integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== + dependencies: + detect-libc "^1.0.2" + mkdirp "^0.5.1" + needle "^2.2.1" + nopt "^4.0.1" + npm-packlist "^1.1.6" + npmlog "^4.0.2" + rc "^1.2.7" + rimraf "^2.6.1" + semver "^5.3.0" + tar "^4" + +nopt@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" + integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= + dependencies: + abbrev "1" + osenv "^0.1.4" + +normalize-package-data@^2.3.2: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + +normalize-path@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" + integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= + dependencies: + remove-trailing-separator "^1.0.1" + +npm-bundled@^1.0.1: + version "1.0.6" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" + integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== + +npm-packlist@^1.1.6: + version "1.4.1" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc" + integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= + dependencies: + path-key "^2.0.0" + +npmlog@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" + integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== + dependencies: + are-we-there-yet "~1.1.2" + console-control-strings "~1.1.0" + gauge "~2.7.3" + set-blocking "~2.0.0" + +number-is-nan@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" + integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= + +nwsapi@^2.0.7: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.1.1.tgz#08d6d75e69fd791bdea31507ffafe8c843b67e9c" + integrity sha512-T5GaA1J/d34AC8mkrFD2O0DR17kwJ702ZOtJOsS8RpbsQZVOC2/xYFb1i/cw+xdM54JIlMuojjDOYct8GIWtwg== + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +object-assign@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= + +object-copy@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" + integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= + dependencies: + copy-descriptor "^0.1.0" + define-property "^0.2.5" + kind-of "^3.0.3" + +object-keys@^1.0.12: + version "1.1.0" + resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.0.tgz#11bd22348dd2e096a045ab06f6c85bcc340fa032" + integrity sha512-6OO5X1+2tYkNyNEx6TsCxEqFfRWaqx6EtMiSbGrw8Ob8v9Ne+Hl8rBAgLBZn5wjEz3s/s6U1WXFUFOcxxAwUpg== + +object-visit@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" + integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= + dependencies: + isobject "^3.0.0" + +object.getownpropertydescriptors@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz#8758c846f5b407adab0f236e0986f14b051caa16" + integrity sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY= + dependencies: + define-properties "^1.1.2" + es-abstract "^1.5.1" + +object.pick@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= + dependencies: + isobject "^3.0.1" + +once@^1.3.0, once@^1.3.1, once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + dependencies: + wrappy "1" + +optimist@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" + integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= + dependencies: + minimist "~0.0.1" + wordwrap "~0.0.2" + +optionator@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" + integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= + dependencies: + deep-is "~0.1.3" + fast-levenshtein "~2.0.4" + levn "~0.3.0" + prelude-ls "~1.1.2" + type-check "~0.3.2" + wordwrap "~1.0.0" + +os-homedir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" + integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= + +os-locale@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" + integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== + dependencies: + execa "^1.0.0" + lcid "^2.0.0" + mem "^4.0.0" + +os-tmpdir@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= + +osenv@^0.1.4: + version "0.1.5" + resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" + integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== + dependencies: + os-homedir "^1.0.0" + os-tmpdir "^1.0.0" + +p-defer@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" + integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= + +p-each-series@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-1.0.0.tgz#930f3d12dd1f50e7434457a22cd6f04ac6ad7f71" + integrity sha1-kw89Et0fUOdDRFeiLNbwSsatf3E= + dependencies: + p-reduce "^1.0.0" + +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= + +p-is-promise@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.0.0.tgz#7554e3d572109a87e1f3f53f6a7d85d1b194f4c5" + integrity sha512-pzQPhYMCAgLAKPWD2jC3Se9fEfrD9npNos0y150EeqZll7akhEgGhTW/slB6lHku8AvYGiJ+YJ5hfHKePPgFWg== + +p-limit@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2" + integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== + dependencies: + p-try "^2.0.0" + +p-locate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" + integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== + dependencies: + p-limit "^2.0.0" + +p-reduce@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" + integrity sha1-GMKw3ZNqRpClKfgjH1ig/bakffo= + +p-try@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" + integrity sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ== + +parse-json@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" + integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= + dependencies: + error-ex "^1.3.1" + json-parse-better-errors "^1.0.1" + +parse5@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608" + integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA== + +pascalcase@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" + integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= + +path-exists@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" + integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= + +path-key@^2.0.0, path-key@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= + +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + +path-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + dependencies: + pify "^3.0.0" + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +pify@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" + integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= + +pirates@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87" + integrity sha512-WuNqLTbMI3tmfef2TKxlQmAiLHKtFhlsCZnPIpuv2Ow0RDVO8lfy1Opf4NUzlMXLjPl+Men7AuVdX6TA+s+uGA== + dependencies: + node-modules-regexp "^1.0.0" + +pkg-dir@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" + integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw== + dependencies: + find-up "^3.0.0" + +pn@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" + integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== + +posix-character-classes@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" + integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= + +prelude-ls@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" + integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= + +pretty-format@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-24.0.0.tgz#cb6599fd73ac088e37ed682f61291e4678f48591" + integrity sha512-LszZaKG665djUcqg5ZQq+XzezHLKrxsA86ZABTozp+oNhkdqa+tG2dX4qa6ERl5c/sRDrAa3lHmwnvKoP+OG/g== + dependencies: + ansi-regex "^4.0.0" + ansi-styles "^3.2.0" + +process-nextick-args@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" + integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw== + +prompts@^2.0.1: + version "2.0.3" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.0.3.tgz#c5ccb324010b2e8f74752aadceeb57134c1d2522" + integrity sha512-H8oWEoRZpybm6NV4to9/1limhttEo13xK62pNvn2JzY0MA03p7s0OjtmhXyon3uJmxiJJVSuUwEJFFssI3eBiQ== + dependencies: + kleur "^3.0.2" + sisteransi "^1.0.0" + +psl@^1.1.24, psl@^1.1.28: + version "1.1.31" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" + integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw== + +pump@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" + integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +read-pkg-up@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978" + integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA== + dependencies: + find-up "^3.0.0" + read-pkg "^3.0.0" + +read-pkg@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" + integrity sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k= + dependencies: + load-json-file "^4.0.0" + normalize-package-data "^2.3.2" + path-type "^3.0.0" + +readable-stream@^2.0.1, readable-stream@^2.0.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +realpath-native@^1.0.0, realpath-native@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" + integrity sha512-wlgPA6cCIIg9gKz0fgAPjnzh4yR/LnXovwuo9hvyGvx3h8nX4+/iLZplfUWasXpqD8BdnGnP5njOFjkUwPzvjA== + dependencies: + util.promisify "^1.0.0" + +regex-not@^1.0.0, regex-not@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" + integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== + dependencies: + extend-shallow "^3.0.2" + safe-regex "^1.1.0" + +remove-trailing-separator@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" + integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= + +repeat-element@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" + integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== + +repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= + +request-promise-core@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.2.tgz#339f6aababcafdb31c799ff158700336301d3346" + integrity sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag== + dependencies: + lodash "^4.17.11" + +request-promise-native@^1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/request-promise-native/-/request-promise-native-1.0.7.tgz#a49868a624bdea5069f1251d0a836e0d89aa2c59" + integrity sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w== + dependencies: + request-promise-core "1.1.2" + stealthy-require "^1.1.1" + tough-cookie "^2.3.3" + +request@^2.87.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + +resolve-cwd@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" + integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo= + dependencies: + resolve-from "^3.0.0" + +resolve-from@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" + integrity sha1-six699nWiBvItuZTM17rywoYh0g= + +resolve-url@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" + integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= + +resolve@1.1.7: + version "1.1.7" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" + integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= + +resolve@1.x, resolve@^1.10.0, resolve@^1.3.2: + version "1.10.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" + integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== + dependencies: + path-parse "^1.0.6" + +ret@~0.1.10: + version "0.1.15" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" + integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== + +rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + +rsvp@^3.3.3: + version "3.6.2" + resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-3.6.2.tgz#2e96491599a96cde1b515d5674a8f7a91452926a" + integrity sha512-OfWGQTb9vnwRjwtA2QwpG2ICclHC3pgXZO5xt8H2EfgDquO0qVdSb5T88L4qJVAEugbS56pAuV4XZM58UX8ulw== + +safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +safe-regex@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" + integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= + dependencies: + ret "~0.1.10" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +sane@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/sane/-/sane-3.1.0.tgz#995193b7dc1445ef1fe41ddfca2faf9f111854c6" + integrity sha512-G5GClRRxT1cELXfdAq7UKtUsv8q/ZC5k8lQGmjEm4HcAl3HzBy68iglyNCmw4+0tiXPCBZntslHlRhbnsSws+Q== + dependencies: + anymatch "^2.0.0" + capture-exit "^1.2.0" + exec-sh "^0.2.0" + execa "^1.0.0" + fb-watchman "^2.0.0" + micromatch "^3.1.4" + minimist "^1.1.1" + walker "~1.0.5" + watch "~0.18.0" + optionalDependencies: + fsevents "^1.2.3" + +sax@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5, semver@^5.5.0: + version "5.6.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" + integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== + +set-blocking@^2.0.0, set-blocking@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= + +set-value@^0.4.3: + version "0.4.3" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1" + integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE= + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.1" + to-object-path "^0.3.0" + +set-value@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274" + integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg== + dependencies: + extend-shallow "^2.0.1" + is-extendable "^0.1.1" + is-plain-object "^2.0.3" + split-string "^3.0.1" + +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= + +shellwords@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" + integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== + +signal-exit@^3.0.0, signal-exit@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= + +sisteransi@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c" + integrity sha512-N+z4pHB4AmUv0SjveWRd6q1Nj5w62m5jodv+GD8lvmbY/83T/rpbJGZOnK5T149OldDj4Db07BSv9xY4K6NTPQ== + +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + +snapdragon-node@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" + integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== + dependencies: + define-property "^1.0.0" + isobject "^3.0.0" + snapdragon-util "^3.0.1" + +snapdragon-util@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" + integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== + dependencies: + kind-of "^3.2.0" + +snapdragon@^0.8.1: + version "0.8.2" + resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" + integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== + dependencies: + base "^0.11.1" + debug "^2.2.0" + define-property "^0.2.5" + extend-shallow "^2.0.1" + map-cache "^0.2.2" + source-map "^0.5.6" + source-map-resolve "^0.5.0" + use "^3.1.0" + +source-map-resolve@^0.5.0: + version "0.5.2" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" + integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== + dependencies: + atob "^2.1.1" + decode-uri-component "^0.2.0" + resolve-url "^0.2.1" + source-map-url "^0.4.0" + urix "^0.1.0" + +source-map-support@^0.5.6: + version "0.5.10" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.10.tgz#2214080bc9d51832511ee2bab96e3c2f9353120c" + integrity sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map-url@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" + integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= + +source-map@^0.5.0, source-map@^0.5.6: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= + +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +spdx-correct@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" + integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== + dependencies: + spdx-expression-parse "^3.0.0" + spdx-license-ids "^3.0.0" + +spdx-exceptions@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" + integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== + +spdx-expression-parse@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" + integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== + dependencies: + spdx-exceptions "^2.1.0" + spdx-license-ids "^3.0.0" + +spdx-license-ids@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.3.tgz#81c0ce8f21474756148bbb5f3bfc0f36bf15d76e" + integrity sha512-uBIcIl3Ih6Phe3XHK1NqboJLdGfwr1UN3k6wSD1dZpmPsIkb8AGNbZYJ1fOBk834+Gxy8rpfDxrS6XLEMZMY2g== + +split-string@^3.0.1, split-string@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" + integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== + dependencies: + extend-shallow "^3.0.0" + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stack-utils@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-1.0.2.tgz#33eba3897788558bebfc2db059dc158ec36cebb8" + integrity sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA== + +static-extend@^0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" + integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= + dependencies: + define-property "^0.2.5" + object-copy "^0.1.0" + +stealthy-require@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" + integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= + +string-length@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" + integrity sha1-1A27aGo6zpYMHP/KVivyxF+DY+0= + dependencies: + astral-regex "^1.0.0" + strip-ansi "^4.0.0" + +string-width@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" + integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= + dependencies: + code-point-at "^1.0.0" + is-fullwidth-code-point "^1.0.0" + strip-ansi "^3.0.0" + +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" + integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== + dependencies: + is-fullwidth-code-point "^2.0.0" + strip-ansi "^4.0.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-ansi@^3.0.0, strip-ansi@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" + integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= + dependencies: + ansi-regex "^2.0.0" + +strip-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" + integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= + dependencies: + ansi-regex "^3.0.0" + +strip-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.0.0.tgz#f78f68b5d0866c20b2c9b8c61b5298508dc8756f" + integrity sha512-Uu7gQyZI7J7gn5qLn1Np3G9vcYGTVqB+lFTytnDJv83dd8T22aGH451P3jueT2/QemInJDfxHB5Tde5OzgG1Ow== + dependencies: + ansi-regex "^4.0.0" + +strip-bom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" + integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= + +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= + +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^6.0.0, supports-color@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3" + integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ== + dependencies: + has-flag "^3.0.0" + +symbol-tree@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6" + integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY= + +tar@^4: + version "4.4.8" + resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" + integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== + dependencies: + chownr "^1.1.1" + fs-minipass "^1.2.5" + minipass "^2.3.4" + minizlib "^1.1.1" + mkdirp "^0.5.0" + safe-buffer "^5.1.2" + yallist "^3.0.2" + +test-exclude@^5.0.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-5.1.0.tgz#6ba6b25179d2d38724824661323b73e03c0c1de1" + integrity sha512-gwf0S2fFsANC55fSeSqpb8BYk6w3FDvwZxfNjeF6FRgvFa43r+7wRiA/Q0IxoRU37wB/LE8IQ4221BsNucTaCA== + dependencies: + arrify "^1.0.1" + minimatch "^3.0.4" + read-pkg-up "^4.0.0" + require-main-filename "^1.0.1" + +throat@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" + integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= + +tmpl@1.0.x: + version "1.0.4" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" + integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= + dependencies: + kind-of "^3.0.2" + +to-regex-range@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" + integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= + dependencies: + is-number "^3.0.0" + repeat-string "^1.6.1" + +to-regex@^3.0.1, to-regex@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" + integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== + dependencies: + define-property "^2.0.2" + extend-shallow "^3.0.2" + regex-not "^1.0.2" + safe-regex "^1.1.0" + +tough-cookie@^2.3.3, tough-cookie@^2.3.4: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ== + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk= + dependencies: + punycode "^2.1.0" + +trim-right@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" + integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= + +ts-jest@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-24.0.0.tgz#3f26bf2ec1fa584863a5a9c29bd8717d549efbf6" + integrity sha512-o8BO3TkMREpAATaFTrXkovMsCpBl2z4NDBoLJuWZcJJj1ijI49UnvDMfVpj+iogn/Jl8Pbhuei5nc/Ti+frEHw== + dependencies: + bs-logger "0.x" + buffer-from "1.x" + fast-json-stable-stringify "2.x" + json5 "2.x" + make-error "1.x" + mkdirp "0.x" + resolve "1.x" + semver "^5.5" + yargs-parser "10.x" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +type-check@~0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" + integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= + dependencies: + prelude-ls "~1.1.2" + +typescript@^3.3.3333: + version "3.3.3333" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.3.3333.tgz#171b2c5af66c59e9431199117a3bcadc66fdcfd6" + integrity sha512-JjSKsAfuHBE/fB2oZ8NxtRTk5iGcg6hkYXMnZ3Wc+b2RSqejEqTaem11mHASMnFilHrax3sLK0GDzcJrekZYLw== + +uglify-js@^3.1.4: + version "3.4.9" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3" + integrity sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q== + dependencies: + commander "~2.17.1" + source-map "~0.6.1" + +union-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" + integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ= + dependencies: + arr-union "^3.1.0" + get-value "^2.0.6" + is-extendable "^0.1.1" + set-value "^0.4.3" + +unset-value@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" + integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= + dependencies: + has-value "^0.3.1" + isobject "^3.0.0" + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +urix@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" + integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= + +use@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" + integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + +util.promisify@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" + integrity sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA== + dependencies: + define-properties "^1.1.2" + object.getownpropertydescriptors "^2.0.3" + +uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== + +validate-npm-package-license@^3.0.1: + version "3.0.4" + resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" + integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== + dependencies: + spdx-correct "^3.0.0" + spdx-expression-parse "^3.0.0" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +w3c-hr-time@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.1.tgz#82ac2bff63d950ea9e3189a58a65625fedf19045" + integrity sha1-gqwr/2PZUOqeMYmlimViX+3xkEU= + dependencies: + browser-process-hrtime "^0.1.2" + +walker@~1.0.5: + version "1.0.7" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb" + integrity sha1-L3+bj9ENZ3JisYqITijRlhjgKPs= + dependencies: + makeerror "1.0.x" + +watch@~0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986" + integrity sha1-KAlUdsbffJDJYxOJkMClQj60uYY= + dependencies: + exec-sh "^0.2.0" + minimist "^1.2.0" + +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + +whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: + version "1.0.5" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz#5abacf777c32166a51d085d6b4f3e7d27113ddb0" + integrity sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw== + dependencies: + iconv-lite "0.4.24" + +whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" + integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== + +whatwg-url@^6.4.1: + version "6.5.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8" + integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +whatwg-url@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.0.0.tgz#fde926fa54a599f3adf82dff25a9f7be02dc6edd" + integrity sha512-37GeVSIJ3kn1JgKyjiYNmSLP1yzbpb29jdmwBSgkD9h40/hyrR/OifpVUndji3tmwGgD8qpw7iQu3RSbCrBpsQ== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + +which-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" + integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= + +which@^1.2.12, which@^1.2.9, which@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +wide-align@^1.1.0: + version "1.1.3" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" + integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== + dependencies: + string-width "^1.0.2 || 2" + +wordwrap@~0.0.2: + version "0.0.3" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" + integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= + +wordwrap@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= + +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +write-file-atomic@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.1.tgz#d0b05463c188ae804396fd5ab2a370062af87529" + integrity sha512-TGHFeZEZMnv+gBFRfjAcxL5bPHrsGKtnb4qsFAws7/vlh+QfwAaySIw4AXP9ZskTTh5GWu3FLuJhsWVdiJPGvg== + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + signal-exit "^3.0.2" + +ws@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f" + integrity sha512-jaHFD6PFv6UgoIVda6qZllptQsMlDEJkTQcybzzXDYM1XO9Y8em691FGMPmM46WGyLU4z9KMgQN+qrux/nhlHA== + dependencies: + async-limiter "~1.0.0" + +xml-name-validator@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" + integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== + +"y18n@^3.2.1 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" + integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== + +yallist@^3.0.0, yallist@^3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" + integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== + +yargs-parser@10.x: + version "10.1.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" + integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== + dependencies: + camelcase "^4.1.0" + +yargs-parser@^11.1.1: + version "11.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" + integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^12.0.2: + version "12.0.5" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" + integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== + dependencies: + cliui "^4.0.0" + decamelize "^1.2.0" + find-up "^3.0.0" + get-caller-file "^1.0.1" + os-locale "^3.0.0" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^2.0.0" + which-module "^2.0.0" + y18n "^3.2.1 || ^4.0.0" + yargs-parser "^11.1.1" From 1c80d37b72803eaec461c0a814a671f51403c283 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 19 Mar 2019 13:31:37 -0700 Subject: [PATCH 02/42] feat (datafile manager): Abortable requests, abort on stop (#2) Summary: Update expected return value of abstract method `makeGetRequest`. Should return a request object with an `abort` method. This will be useful in the Node datafile manager for aborting the request on `stop`. Test plan: Updated unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4309 --- .../httpPollingDatafileManager.spec.ts | 37 +++++++++++++------ packages/datafile-manager/src/http.ts | 5 +++ .../src/httpPollingDatafileManager.ts | 23 +++++++++--- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index e955f2f58..d9c64b710 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -15,7 +15,7 @@ */ import HTTPPollingDatafileManager from '../src/httpPollingDatafileManager' -import { Headers, Response } from '../src/http' +import { Headers, AbortableRequest, Response } from '../src/http' import { TimeoutFactory } from '../src/timeoutFactory' import { DatafileManagerConfig } from '../src/datafileManager'; @@ -46,14 +46,16 @@ class TestDatafileManager extends HTTPPollingDatafileManager { responsePromises: Promise[] = [] - makeGetRequest(url: string, headers: Headers): Promise { + makeGetRequest(url: string, headers: Headers): AbortableRequest { const nextResponse: Response | undefined = this.queuedResponses.pop() + let responsePromise: Promise if (nextResponse === undefined) { - return Promise.reject('No responses queued') + responsePromise = Promise.reject('No responses queued') + } else { + responsePromise = Promise.resolve(nextResponse) } - const respPromise = Promise.resolve(nextResponse) - this.responsePromises.push(respPromise) - return respPromise + this.responsePromises.push(responsePromise) + return { responsePromise, abort: jest.fn() } } } @@ -103,10 +105,6 @@ describe('httpPollingDatafileManager', () => { manager = createTestManager({ sdkKey: '123', updateInterval: 10 }) }) - afterEach(() => { - manager.stop() - }) - describe('initial state', () => { it('returns null from get before becoming ready', () => { expect(manager.get()).toBeNull() @@ -239,6 +237,23 @@ describe('httpPollingDatafileManager', () => { expect(manager.get()).toBe('{"foo": "bar"}') }) + it('calls abort on the current request if there is a current request when stop is called', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo2": "bar2"}', + headers: {}, + } + ) + const makeGetRequestSpy = jest.spyOn(manager, 'makeGetRequest') + manager.start() + const currentRequest = makeGetRequestSpy.mock.results[0] + expect(currentRequest.type).toBe('return') + expect(currentRequest.value.abort).toBeCalledTimes(0) + manager.stop() + expect(currentRequest.value.abort).toBeCalledTimes(1) + }) + it('can fail to become ready on the initial request, but succeed after a later polling update', async () => { manager.queuedResponses.push( { @@ -366,7 +381,7 @@ describe('httpPollingDatafileManager', () => { body: '{"foo": "bar"}', headers: {}, }) - manager.makeGetRequest = () => Promise.reject(new Error('Could not connect')) + manager.makeGetRequest = () => ({ abort() {}, responsePromise: Promise.reject(new Error('Could not connect')) }) manager.start() let didReject = false try { diff --git a/packages/datafile-manager/src/http.ts b/packages/datafile-manager/src/http.ts index b94d5b169..64ed505fc 100644 --- a/packages/datafile-manager/src/http.ts +++ b/packages/datafile-manager/src/http.ts @@ -23,3 +23,8 @@ export interface Response { body: string headers: Headers } + +export interface AbortableRequest { + abort(): void + responsePromise: Promise +} diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 4a838a4df..09b99f612 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -17,7 +17,7 @@ import { getLogger } from '@optimizely/js-sdk-logging' import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'; import EventEmitter from './eventEmitter' -import { Response, Headers } from './http'; +import { AbortableRequest, Response, Headers } from './http'; import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE, SDK_KEY_TOKEN } from './config' import { TimeoutFactory, DEFAULT_TIMEOUT_FACTORY } from './timeoutFactory' @@ -31,8 +31,10 @@ function isValidUpdateInterval(updateInterval: number): boolean { export default abstract class HTTPPollingDatafileManager implements DatafileManager { // Make an HTTP get request to the given URL with the given headers - // Return a promise for a Response. If we can't get a response, the promise is rejected - protected abstract makeGetRequest(reqUrl: string, headers: Headers): Promise + // Return an AbortableRequest, which has a promise for a Response. + // If we can't get a response, the promise is rejected. + // The request will be aborted if the manager is stopped while the request is in flight. + protected abstract makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest public readonly onReady: Promise @@ -62,6 +64,8 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private timeoutFactory: TimeoutFactory + private currentRequest?: AbortableRequest + constructor(config: DatafileManagerConfig) { const { datafile, @@ -125,7 +129,14 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.cancelTimeout() this.cancelTimeout = undefined } + this.emitter.removeAllListeners() + + if (this.currentRequest) { + this.currentRequest.abort() + this.currentRequest = undefined + } + return Promise.resolve() } @@ -178,6 +189,8 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana return } + this.currentRequest = undefined + if (this.liveUpdates) { this.scheduleNextUpdate() } @@ -196,7 +209,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana const datafileUrl = this.getUrl(this.sdkKey) logger.debug('Making datafile request to url %s with headers: %s', datafileUrl, () => JSON.stringify(headers)) - const datafileFetch = this.makeGetRequest(datafileUrl, headers) + this.currentRequest = this.makeGetRequest(datafileUrl, headers) const onFetchComplete = () => { this.onFetchComplete() @@ -207,7 +220,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana const logMakeGetRequestError = (err: any) => { this.logMakeGetRequestError(err) } - datafileFetch + this.currentRequest.responsePromise .then(tryUpdatingDatafile, logMakeGetRequestError) .then(onFetchComplete, onFetchComplete) } From 8135a5136ca32e122ad13b7d7e733d49551ec6bd Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 19 Mar 2019 16:42:56 -0700 Subject: [PATCH 03/42] feat (datafile manager): Node datafile manager (#3) Summary: Adds `NodeDatafileManager` class, extending `HttpPollingDatafileManager` with Node-specific http requesting logic. Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4309 --- .../httpPollingDatafileManager.spec.ts | 22 +-- .../__test__/nodeDatafileManager.spec.ts | 85 +++++++++++ .../__test__/nodeRequest.spec.ts | 144 ++++++++++++++++++ .../__test__/testTimeoutFactory.ts | 21 +++ packages/datafile-manager/package.json | 2 + .../src/nodeDatafileManager.ts | 25 +++ packages/datafile-manager/src/nodeRequest.ts | 111 ++++++++++++++ packages/datafile-manager/yarn.lock | 90 ++++++++++- 8 files changed, 477 insertions(+), 23 deletions(-) create mode 100644 packages/datafile-manager/__test__/nodeDatafileManager.spec.ts create mode 100644 packages/datafile-manager/__test__/nodeRequest.spec.ts create mode 100644 packages/datafile-manager/__test__/testTimeoutFactory.ts create mode 100644 packages/datafile-manager/src/nodeDatafileManager.ts create mode 100644 packages/datafile-manager/src/nodeRequest.ts diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index d9c64b710..842cb3d73 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -16,28 +16,8 @@ import HTTPPollingDatafileManager from '../src/httpPollingDatafileManager' import { Headers, AbortableRequest, Response } from '../src/http' -import { TimeoutFactory } from '../src/timeoutFactory' import { DatafileManagerConfig } from '../src/datafileManager'; - -class TestTimeoutFactory implements TimeoutFactory { - timeoutFns: Array<() => void> = [] - - cancelFns: Array<() => void> = [] - - setTimeout(onTimeout: () => void, timeout: number): () => void { - const cancelFn = jest.fn() - this.timeoutFns.push(() => { - onTimeout() - }) - this.cancelFns.push(cancelFn) - return cancelFn - } - - cleanup() { - this.timeoutFns = [] - this.cancelFns = [] - } -} +import TestTimeoutFactory from './testTimeoutFactory' // Test implementation: // - Does not make any real requests: just resolves with queued responses (tests push onto queuedResponses) diff --git a/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts new file mode 100644 index 000000000..015de7dc7 --- /dev/null +++ b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import NodeDatafileManager from '../src/nodeDatafileManager' +import * as nodeRequest from '../src/nodeRequest' +import { Headers, AbortableRequest } from '../src/http' +import TestTimeoutFactory from './testTimeoutFactory' + +describe('nodeDatafileManager', () => { + const testTimeoutFactory: TestTimeoutFactory = new TestTimeoutFactory() + + let makeGetRequestSpy: jest.SpyInstance + beforeEach(() => { + makeGetRequestSpy = jest.spyOn(nodeRequest, 'makeGetRequest') + }) + + afterEach(() => { + jest.restoreAllMocks() + testTimeoutFactory.cleanup() + }) + + it('calls nodeEnvironment.makeGetRequest when started', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: {}, + }) + }) + + const manager = new NodeDatafileManager({ + sdkKey: '1234', + liveUpdates: false, + }) + manager.start() + expect(makeGetRequestSpy).toBeCalledTimes(1) + expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') + expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({}) + + await manager.onReady + await manager.stop() + }) + + it('calls nodeEnvironment.makeGetRequest for live update requests', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + }) + }) + const manager = new NodeDatafileManager({ + sdkKey: '1234', + liveUpdates: true, + timeoutFactory: testTimeoutFactory, + }) + manager.start() + await manager.onReady + testTimeoutFactory.timeoutFns[0]() + expect(makeGetRequestSpy).toBeCalledTimes(2) + expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') + expect(makeGetRequestSpy.mock.calls[1][1]).toEqual({ + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT' + }) + + await manager.stop() + }) +}) diff --git a/packages/datafile-manager/__test__/nodeRequest.spec.ts b/packages/datafile-manager/__test__/nodeRequest.spec.ts new file mode 100644 index 000000000..6e85ff4f7 --- /dev/null +++ b/packages/datafile-manager/__test__/nodeRequest.spec.ts @@ -0,0 +1,144 @@ +/** + * 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. + */ + +import nock from 'nock' +import { makeGetRequest } from '../src/nodeRequest' + +beforeAll(() => { + nock.disableNetConnect() +}) + +afterAll(() => { + nock.enableNetConnect() +}) + +describe('nodeEnvironment', () => { + const host = 'https://cdn.optimizely.com' + const path = '/datafiles/123.json' + + afterEach(async () => { + nock.cleanAll() + }) + + describe('makeGetRequest', () => { + it('returns a 200 response back to its superclass', async () => { + const scope = nock(host) + .get(path) + .reply(200, '{"foo":"bar"}') + const req = makeGetRequest(`${host}${path}`, {}) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: {}, + }) + scope.done() + }) + + it('returns a 404 response back to its superclass', async () => { + const scope = nock(host) + .get(path) + .reply(404, '') + const req = makeGetRequest(`${host}${path}`, {}) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 404, + body: '', + headers: {}, + }) + scope.done() + }) + + it('includes headers from the headers argument in the request', async () => { + const scope = nock(host) + .matchHeader('if-modified-since', 'Fri, 08 Mar 2019 18:57:18 GMT') + .get(path) + .reply(304, '') + const req = makeGetRequest(`${host}${path}`, { + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT', + }) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 304, + body: '', + headers: {}, + }) + scope.done() + }) + + it('includes headers from the response in the eventual response in the return value', async () => { + const scope = nock(host) + .get(path) + .reply(200, { foo: 'bar' }, { + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }) + const req = makeGetRequest(`${host}${path}`, {}) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'content-type': 'application/json', + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, + }) + scope.done() + }) + + it('handles a URL with a query string', async () => { + const pathWithQuery = '/datafiles/123.json?from_my_app=true' + const scope = nock(host) + .get(pathWithQuery) + .reply(200, { foo: 'bar' }) + const req = makeGetRequest(`${host}${pathWithQuery}`, {}) + await req.responsePromise + scope.done() + }) + + it('handles a URL with http protocol (not https)', async () => { + const httpHost = 'http://cdn.optimizely.com' + const scope = nock(httpHost) + .get(path) + .reply(200, '{"foo":"bar"}') + const req = makeGetRequest(`${httpHost}${path}`, {}) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: {} + }) + scope.done() + }) + + it('returns a rejected response promise when the URL protocol is unsupported', async () => { + const invalidProtocolUrl = 'ftp://something/datafiles/123.json' + const req = makeGetRequest(invalidProtocolUrl, {}) + await expect(req.responsePromise).rejects.toThrow() + }) + + it('returns a rejected promise when there is a request error', async () => { + const scope = nock(host) + .get(path) + .replyWithError({ + message: 'Connection error', + code: 'CONNECTION_ERROR', + }) + const req = makeGetRequest(`${host}${path}`, {}) + await expect(req.responsePromise).rejects.toThrow() + scope.done() + }) + }) +}) diff --git a/packages/datafile-manager/__test__/testTimeoutFactory.ts b/packages/datafile-manager/__test__/testTimeoutFactory.ts new file mode 100644 index 000000000..c4560064b --- /dev/null +++ b/packages/datafile-manager/__test__/testTimeoutFactory.ts @@ -0,0 +1,21 @@ +import { TimeoutFactory } from '../src/timeoutFactory' + +export default class TestTimeoutFactory implements TimeoutFactory { + timeoutFns: Array<() => void> = [] + + cancelFns: Array<() => void> = [] + + setTimeout(onTimeout: () => void, timeout: number): () => void { + const cancelFn = jest.fn() + this.timeoutFns.push(() => { + onTimeout() + }) + this.cancelFns.push(cancelFn) + return cancelFn + } + + cleanup() { + this.timeoutFns = [] + this.cancelFns = [] + } +} diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index 172f62eb0..ad0b2c707 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -32,7 +32,9 @@ }, "devDependencies": { "@types/jest": "^24.0.9", + "@types/nock": "^9.3.1", "jest": "^24.1.0", + "nock": "^10.0.6", "ts-jest": "^24.0.0", "typescript": "^3.3.3333" }, diff --git a/packages/datafile-manager/src/nodeDatafileManager.ts b/packages/datafile-manager/src/nodeDatafileManager.ts new file mode 100644 index 000000000..6fa0f618c --- /dev/null +++ b/packages/datafile-manager/src/nodeDatafileManager.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +import { makeGetRequest } from './nodeRequest' +import HttpPollingDatafileManager from './httpPollingDatafileManager' +import { Headers, AbortableRequest } from './http'; + +export default class NodeDatafileManager extends HttpPollingDatafileManager { + protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + return makeGetRequest(reqUrl, headers) + } +} diff --git a/packages/datafile-manager/src/nodeRequest.ts b/packages/datafile-manager/src/nodeRequest.ts new file mode 100644 index 000000000..5ff5f1aed --- /dev/null +++ b/packages/datafile-manager/src/nodeRequest.ts @@ -0,0 +1,111 @@ +/** + * 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. + */ + +import http from 'http' +import https from 'https' +import url from 'url' +import { Headers, AbortableRequest, Response } from './http' + +// Shared signature between http.request and https.request +type ClientRequestCreator = (options: http.RequestOptions) => http.ClientRequest + +function getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOptions { + return { + protocol: url.protocol, + host: url.host, + path: url.path, + } +} + +function getResponseFromRequest(request: http.ClientRequest): Promise { + // TODO: When we drop support for Node 6, consider using util.promisify instead of + // constructing own Promise + return new Promise((resolve, reject) => { + request.once('response', (incomingMessage: http.IncomingMessage) => { + if (request.aborted) { + return + } + + incomingMessage.setEncoding('utf8') + + let responseData = '' + incomingMessage.on('data', (chunk: string) => { + if (!request.aborted) { + responseData += chunk + } + }) + + incomingMessage.on('end', () => { + if (request.aborted) { + return + } + + resolve({ + statusCode: incomingMessage.statusCode, + body: responseData, + headers: incomingMessage.headers, + }) + }) + }) + + request.on('error', (err: any) => { + if (err instanceof Error) { + reject(err) + } else if (typeof err === 'string') { + reject(new Error(err)) + } else { + reject(new Error('Request error')) + } + }) + }) +} + +export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + // TODO: Use non-legacy URL parsing when we drop support for Node 6 + const parsedUrl = url.parse(reqUrl) + + let requester: ClientRequestCreator + if (parsedUrl.protocol === 'http:') { + requester = http.request + } else if (parsedUrl.protocol === 'https:') { + requester = https.request + } else { + return { + responsePromise: Promise.reject( + new Error(`Unsupported protocol: ${parsedUrl.protocol}`), + ), + abort() {}, + } + } + + const requestOptions: http.RequestOptions = { + ...getRequestOptionsFromUrl(parsedUrl), + method: 'GET', + headers, + } + + const request = requester(requestOptions) + const responsePromise = getResponseFromRequest(request) + + request.end() + + return { + abort() { + request.abort() + }, + responsePromise, + } +} diff --git a/packages/datafile-manager/yarn.lock b/packages/datafile-manager/yarn.lock index 57cda2d58..09dd3ed1d 100644 --- a/packages/datafile-manager/yarn.lock +++ b/packages/datafile-manager/yarn.lock @@ -157,6 +157,18 @@ dependencies: "@types/jest-diff" "*" +"@types/nock@^9.3.1": + version "9.3.1" + resolved "https://registry.yarnpkg.com/@types/nock/-/nock-9.3.1.tgz#7d761a43a10aebc7ec6bae29d89afc6cbffa5d30" + integrity sha512-eOVHXS5RnWOjTVhu3deCM/ruy9E6JCgeix2g7wpFiekQh3AaEAK1cz43tZDukKmtSmQnwvSySq7ubijCA32I7Q== + dependencies: + "@types/node" "*" + +"@types/node@*": + version "11.11.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.3.tgz#7c6b0f8eaf16ae530795de2ad1b85d34bf2f5c58" + integrity sha512-wp6IOGu1lxsfnrD+5mX6qwSwWuqsdkKKxTN4aQc4wByHAKZJf9/D4KXPQ1POUjEbnCP5LMggB0OEFNY9OTsMqg== + "@types/node@^11.10.5": version "11.10.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.5.tgz#fbaca34086bdc118011e1f05c47688d432f2d571" @@ -309,6 +321,11 @@ assert-plus@1.0.0, assert-plus@^1.0.0: resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -505,6 +522,18 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= +chai@^4.1.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.2.0.tgz#760aa72cf20e3795e84b12877ce0e83737aa29e5" + integrity sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.2" + deep-eql "^3.0.1" + get-func-name "^2.0.0" + pathval "^1.1.0" + type-detect "^4.0.5" + chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -514,6 +543,11 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +check-error@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" + integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= + chownr@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494" @@ -685,6 +719,18 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +deep-eql@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-3.0.1.tgz#dfc9404400ad1c8fe023e7da1df1c147c4b444df" + integrity sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw== + dependencies: + type-detect "^4.0.0" + +deep-equal@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha1-9dJgKStmDghO/0zbyfCK0yR0SLU= + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -1053,6 +1099,11 @@ get-caller-file@^1.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== +get-func-name@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41" + integrity sha1-6td0q+5y4gQJQzoGY2YCPdaIekE= + get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -1901,7 +1952,7 @@ json-schema@0.2.3: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-stringify-safe@~5.0.1: +json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= @@ -2000,7 +2051,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.11: +lodash@^4.17.11, lodash@^4.17.5: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -2210,6 +2261,21 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nock@^10.0.6: + version "10.0.6" + resolved "https://registry.yarnpkg.com/nock/-/nock-10.0.6.tgz#e6d90ee7a68b8cfc2ab7f6127e7d99aa7d13d111" + integrity sha512-b47OWj1qf/LqSQYnmokNWM8D88KvUl2y7jT0567NB3ZBAZFz2bWp2PC81Xn7u8F2/vJxzkzNZybnemeFa7AZ2w== + dependencies: + chai "^4.1.2" + debug "^4.1.0" + deep-equal "^1.0.0" + json-stringify-safe "^5.0.1" + lodash "^4.17.5" + mkdirp "^0.5.0" + propagate "^1.0.0" + qs "^6.5.1" + semver "^5.5.0" + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -2503,6 +2569,11 @@ path-type@^3.0.0: dependencies: pify "^3.0.0" +pathval@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0" + integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" @@ -2563,6 +2634,11 @@ prompts@^2.0.1: kleur "^3.0.2" sisteransi "^1.0.0" +propagate@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/propagate/-/propagate-1.0.0.tgz#00c2daeedda20e87e3782b344adba1cddd6ad709" + integrity sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk= + psl@^1.1.24, psl@^1.1.28: version "1.1.31" resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" @@ -2586,6 +2662,11 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== +qs@^6.5.1: + version "6.6.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.6.0.tgz#a99c0f69a8d26bf7ef012f871cdabb0aee4424c2" + integrity sha512-KIJqT9jQJDQx5h5uAVPimw6yVg2SekOKu959OCtktD3FjzbpvaPr8i4zzg07DOMz+igA4W/aNM7OV8H37pFYfA== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -3207,6 +3288,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-detect@^4.0.0, type-detect@^4.0.5: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + typescript@^3.3.3333: version "3.3.3333" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.3.3333.tgz#171b2c5af66c59e9431199117a3bcadc66fdcfd6" From 2eec4e80ac4bb36ee8bb642d443d38bcd3b14e90 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 19 Mar 2019 16:58:53 -0700 Subject: [PATCH 04/42] fix (datafile manager): Update datafile manager interface: onReady becomes a method (#4) --- .../httpPollingDatafileManager.spec.ts | 28 +++++------ .../__test__/nodeDatafileManager.spec.ts | 4 +- .../datafile-manager/src/datafileManager.ts | 2 +- .../src/httpPollingDatafileManager.ts | 46 ++++++++++--------- 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 842cb3d73..3d295ad2a 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -75,7 +75,7 @@ describe('httpPollingDatafileManager', () => { headers: {} }) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') }) }) @@ -102,7 +102,7 @@ describe('httpPollingDatafileManager', () => { manager.start() expect(makeGetRequestSpy).toBeCalledTimes(1) expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/123.json') - await manager.onReady + await manager.onReady() }) it('after being started, fetches the datafile and resolves onReady', async () => { @@ -112,7 +112,7 @@ describe('httpPollingDatafileManager', () => { headers: {}, }) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') }) @@ -127,7 +127,7 @@ describe('httpPollingDatafileManager', () => { const setTimeoutSpy: jest.SpyInstance<() => void, [() => void, number]> = jest.spyOn(testTimeoutFactory, 'setTimeout') manager.start() - await manager.onReady + await manager.onReady() expect(setTimeoutSpy).toBeCalledTimes(1) expect(setTimeoutSpy.mock.calls[0][1]).toBe(10) }) @@ -155,7 +155,7 @@ describe('httpPollingDatafileManager', () => { manager.on('update', updateFn) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') expect(updateFn).toBeCalledTimes(0) @@ -184,7 +184,7 @@ describe('httpPollingDatafileManager', () => { ) manager.start() - await manager.onReady + await manager.onReady() expect(testTimeoutFactory.timeoutFns.length).toBe(1) expect(testTimeoutFactory.cancelFns.length).toBe(1) @@ -207,7 +207,7 @@ describe('httpPollingDatafileManager', () => { ) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') testTimeoutFactory.timeoutFns[0]() expect(manager.responsePromises.length).toBe(2) @@ -255,7 +255,7 @@ describe('httpPollingDatafileManager', () => { expect(testTimeoutFactory.timeoutFns.length).toBe(1) // Trigger the update, should fetch the next response which should succeed, then we get ready testTimeoutFactory.timeoutFns[0]() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') }) @@ -280,7 +280,7 @@ describe('httpPollingDatafileManager', () => { manager.on('update', updateFn) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') // First response promise was for the initial 200 response expect(manager.responsePromises.length).toBe(1) @@ -310,7 +310,7 @@ describe('httpPollingDatafileManager', () => { } ) manager.start() - await manager.onReady + await manager.onReady() const makeGetRequestSpy = jest.spyOn(manager, 'makeGetRequest') testTimeoutFactory.timeoutFns[0]() expect(makeGetRequestSpy).toBeCalledTimes(1) @@ -337,7 +337,7 @@ describe('httpPollingDatafileManager', () => { headers: {}, }) manager.start() - await manager.onReady + await manager.onReady() expect(manager.get()).toBe('{"foo": "bar"}') }) @@ -350,7 +350,7 @@ describe('httpPollingDatafileManager', () => { const updateFn = jest.fn() manager.on('update', updateFn) manager.start() - await manager.onReady + await manager.onReady() expect(testTimeoutFactory.timeoutFns.length).toBe(0) }) @@ -365,7 +365,7 @@ describe('httpPollingDatafileManager', () => { manager.start() let didReject = false try { - await manager.onReady + await manager.onReady() } catch (e) { didReject = true } @@ -392,7 +392,7 @@ describe('httpPollingDatafileManager', () => { manager.start() expect(makeGetRequestSpy).toBeCalledTimes(1) expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://localhost:5556/datafiles/456') - await manager.onReady + await manager.onReady() }) }) }) diff --git a/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts index 015de7dc7..9049f1dce 100644 --- a/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts @@ -51,7 +51,7 @@ describe('nodeDatafileManager', () => { expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({}) - await manager.onReady + await manager.onReady() await manager.stop() }) @@ -72,7 +72,7 @@ describe('nodeDatafileManager', () => { timeoutFactory: testTimeoutFactory, }) manager.start() - await manager.onReady + await manager.onReady() testTimeoutFactory.timeoutFns[0]() expect(makeGetRequestSpy).toBeCalledTimes(2) expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index c22d37c06..3e6b02dcc 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -34,7 +34,7 @@ interface Managed { export interface DatafileManager extends Managed { get: () => string | null on: (eventName: string, listener: DatafileUpdateListener) => () => void - onReady: Promise + onReady: () => Promise } export enum CacheDirective { diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 09b99f612..a44cf6239 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -36,17 +36,17 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana // The request will be aborted if the manager is stopped while the request is in flight. protected abstract makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest - public readonly onReady: Promise - private currentDatafile: string | null private readonly sdkKey: string - private isOnReadySettled: boolean + private readonly readyPromise: Promise + + private isReadyPromiseSettled: boolean - private onReadyResolver: () => void + private readyPromiseResolver: () => void - private onReadyRejecter: (err: Error) => void + private readyPromiseRejecter: (err: Error) => void private readonly emitter: EventEmitter @@ -84,12 +84,12 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.currentDatafile = null } - this.isOnReadySettled = false - this.onReadyResolver = () => {} - this.onReadyRejecter = () => {} - this.onReady = new Promise((resolve, reject) => { - this.onReadyResolver = resolve - this.onReadyRejecter = reject + this.isReadyPromiseSettled = false + this.readyPromiseResolver = () => {} + this.readyPromiseRejecter = () => {} + this.readyPromise = new Promise((resolve, reject) => { + this.readyPromiseResolver = resolve + this.readyPromiseRejecter = reject }) this.isStarted = false @@ -140,6 +140,10 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana return Promise.resolve() } + onReady(): Promise { + return this.readyPromise + } + on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void) { return this.emitter.on(eventName, listener) } @@ -173,8 +177,8 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana if (datafile !== null) { logger.info('Updating datafile from response') this.currentDatafile = datafile - if (!this.isOnReadySettled) { - this.resolveOnReady() + if (!this.isReadyPromiseSettled) { + this.resolveReadyPromise() } else if (this.liveUpdates) { const datafileUpdate: DatafileUpdate = { datafile, @@ -194,9 +198,9 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana if (this.liveUpdates) { this.scheduleNextUpdate() } - if (!this.isOnReadySettled && !this.liveUpdates) { + if (!this.isReadyPromiseSettled && !this.liveUpdates) { // We will never resolve ready, so reject it - this.rejectOnReady(new Error('Failed to become ready')) + this.rejectReadyPromise(new Error('Failed to become ready')) } } @@ -225,14 +229,14 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana .then(onFetchComplete, onFetchComplete) } - private resolveOnReady(): void { - this.onReadyResolver() - this.isOnReadySettled = true + private resolveReadyPromise(): void { + this.readyPromiseResolver() + this.isReadyPromiseSettled = true } - private rejectOnReady(err: Error): void { - this.onReadyRejecter(err) - this.isOnReadySettled = true + private rejectReadyPromise(err: Error): void { + this.readyPromiseRejecter(err) + this.isReadyPromiseSettled = true } private scheduleNextUpdate(): void { From 8cbd16c841c3bd361b520a4cff39427996c98f7e Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Mar 2019 13:11:42 -0700 Subject: [PATCH 05/42] refactor (datafile manager): Update Headers type to only store at-most 1 value per header name (#5) Summary: Change `Headers` type to have at-most 1 value per header name. This is sufficient for the current requirements (which only use If-Modified-Since and Last-Modified), and simplifies the code. We can introduce a richer type if we need to deal with multiple values for headers. Test plan: Existing unit tests. This should be a pure refactor with no behavior change. --- packages/datafile-manager/src/http.ts | 9 +++++- .../src/httpPollingDatafileManager.ts | 8 +---- packages/datafile-manager/src/nodeRequest.ts | 31 ++++++++++++++++++- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/datafile-manager/src/http.ts b/packages/datafile-manager/src/http.ts index 64ed505fc..fb4dece7f 100644 --- a/packages/datafile-manager/src/http.ts +++ b/packages/datafile-manager/src/http.ts @@ -14,8 +14,15 @@ * limitations under the License. */ +/** + * Headers is the interface that bridges between the abstract datafile manager and + * any Node-or-browser-specific http header types. + * It's simplified and can only store one value per header name. + * We can extend or replace this type if requirements change and we need + * to work with multiple values per header name. + */ export interface Headers { - [header: string]: string | string[] | undefined; + [header: string]: string | undefined } export interface Response { diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index a44cf6239..046e0ef2e 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -262,15 +262,9 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private trySavingLastModified(headers: Headers): void { const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified'] - if (typeof lastModifiedHeader === 'string') { + if (typeof lastModifiedHeader !== 'undefined') { this.lastResponseLastModified = lastModifiedHeader logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified) - } else if (typeof lastModifiedHeader === 'undefined') { - } else { // array - if (lastModifiedHeader.length === 1) { - this.lastResponseLastModified = lastModifiedHeader[0] - logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified) - } } } } diff --git a/packages/datafile-manager/src/nodeRequest.ts b/packages/datafile-manager/src/nodeRequest.ts index 5ff5f1aed..5caf25e08 100644 --- a/packages/datafile-manager/src/nodeRequest.ts +++ b/packages/datafile-manager/src/nodeRequest.ts @@ -30,6 +30,35 @@ function getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOpti } } +/** + * Convert incomingMessage.headers (which has type http.IncomingHttpHeaders) into our Headers type defined in src/http.ts. + * + * Our Headers type is simplified and can't represent mutliple values for the same header name. + * + * We don't currently need multiple values support, and the consumer code becomes simpler if it can assume at-most 1 value + * per header name. + * + */ +function createHeadersFromNodeIncomingMessage( + incomingMessage: http.IncomingMessage, +): Headers { + const headers: Headers = {} + Object.keys(incomingMessage.headers).forEach(headerName => { + const headerValue = incomingMessage.headers[headerName] + if (typeof headerValue === 'string') { + headers[headerName] = headerValue + } else if (typeof headerValue === 'undefined') { + } else { + // array + if (headerValue.length > 0) { + // We don't care about multiple values - just take the first one + headers[headerName] = headerValue[0] + } + } + }) + return headers +} + function getResponseFromRequest(request: http.ClientRequest): Promise { // TODO: When we drop support for Node 6, consider using util.promisify instead of // constructing own Promise @@ -56,7 +85,7 @@ function getResponseFromRequest(request: http.ClientRequest): Promise resolve({ statusCode: incomingMessage.statusCode, body: responseData, - headers: incomingMessage.headers, + headers: createHeadersFromNodeIncomingMessage(incomingMessage), }) }) }) From c8650e3cfa2dc2b9213fde4f484609ebe1f0cd00 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 22 Mar 2019 15:39:32 -0700 Subject: [PATCH 06/42] feat (datafile manager)[OASIS-4310]: Browser datafile manager (#6) Summary: Add `BrowserDatafileManager`, similar to `NodeDatafileManager`: a subclass of `HttpPollingDatafileManager` implementing `makeGetRequest` using `XMLHttpRequest`. The caching described in the design is not yet implemented. Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4310 --- .../__test__/browserDatafileManager.spec.ts | 85 +++++++++++ .../__test__/browserRequest.spec.ts | 134 ++++++++++++++++++ packages/datafile-manager/package.json | 2 + .../src/browserDatafileManager.ts | 25 ++++ .../datafile-manager/src/browserRequest.ts | 86 +++++++++++ packages/datafile-manager/yarn.lock | 99 ++++++++++++- 6 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 packages/datafile-manager/__test__/browserDatafileManager.spec.ts create mode 100644 packages/datafile-manager/__test__/browserRequest.spec.ts create mode 100644 packages/datafile-manager/src/browserDatafileManager.ts create mode 100644 packages/datafile-manager/src/browserRequest.ts diff --git a/packages/datafile-manager/__test__/browserDatafileManager.spec.ts b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts new file mode 100644 index 000000000..f162d1dcf --- /dev/null +++ b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts @@ -0,0 +1,85 @@ +/** + * 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. + */ + +import BrowserDatafileManager from '../src/browserDatafileManager' +import * as browserRequest from '../src/browserRequest' +import { Headers, AbortableRequest } from '../src/http' +import TestTimeoutFactory from './testTimeoutFactory' + +describe('browserDatafileManager', () => { + const testTimeoutFactory: TestTimeoutFactory = new TestTimeoutFactory() + + let makeGetRequestSpy: jest.SpyInstance + beforeEach(() => { + makeGetRequestSpy = jest.spyOn(browserRequest, 'makeGetRequest') + }) + + afterEach(() => { + jest.restoreAllMocks() + testTimeoutFactory.cleanup() + }) + + it('calls makeGetRequest when started', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: {}, + }) + }) + + const manager = new BrowserDatafileManager({ + sdkKey: '1234', + liveUpdates: false, + }) + manager.start() + expect(makeGetRequestSpy).toBeCalledTimes(1) + expect(makeGetRequestSpy.mock.calls[0][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') + expect(makeGetRequestSpy.mock.calls[0][1]).toEqual({}) + + await manager.onReady() + await manager.stop() + }) + + it('calls makeGetRequest for live update requests', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + }) + }) + const manager = new BrowserDatafileManager({ + sdkKey: '1234', + liveUpdates: true, + timeoutFactory: testTimeoutFactory, + }) + manager.start() + await manager.onReady() + testTimeoutFactory.timeoutFns[0]() + expect(makeGetRequestSpy).toBeCalledTimes(2) + expect(makeGetRequestSpy.mock.calls[1][0]).toBe('https://cdn.optimizely.com/datafiles/1234.json') + expect(makeGetRequestSpy.mock.calls[1][1]).toEqual({ + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:17 GMT' + }) + + await manager.stop() + }) +}) diff --git a/packages/datafile-manager/__test__/browserRequest.spec.ts b/packages/datafile-manager/__test__/browserRequest.spec.ts new file mode 100644 index 000000000..9bcf156e3 --- /dev/null +++ b/packages/datafile-manager/__test__/browserRequest.spec.ts @@ -0,0 +1,134 @@ +/** + * @jest-environment jsdom + */ + +// TODO: It doesn't work unless the jest-enviroment comment is at the top... +// ...so if we need the license header at the top, we have to fix this + +/** + * 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. + */ + +import { + SinonFakeXMLHttpRequest, + SinonFakeXMLHttpRequestStatic, + useFakeXMLHttpRequest, +} from 'sinon' +import { makeGetRequest } from '../src/browserRequest' + +describe('browserRequest', () => { + describe('makeGetRequest', () => { + let mockXHR: SinonFakeXMLHttpRequestStatic + let xhrs: SinonFakeXMLHttpRequest[] + beforeEach(() => { + xhrs = [] + mockXHR = useFakeXMLHttpRequest() + mockXHR.onCreate = req => xhrs.push(req) + }) + + afterEach(() => { + mockXHR.restore() + }) + + it('makes a GET request to the argument URL', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}) + + expect(xhrs.length).toBe(1) + const xhr = xhrs[0] + const { url, method } = xhr + expect({ url, method }).toEqual({ + url: 'https://cdn.optimizely.com/datafiles/123.json', + method: 'GET', + }) + + xhr.respond(200, {}, '{"foo":"bar"}') + + await req.responsePromise + }) + + it('returns a 200 response back to its superclass', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}) + + const xhr = xhrs[0] + xhr.respond(200, {}, '{"foo":"bar"}') + + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + headers: {}, + body: '{"foo":"bar"}', + }) + }) + + it('returns a 404 response back to its superclass', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}) + + const xhr = xhrs[0] + xhr.respond(404, {}, '') + + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 404, + headers: {}, + body: '', + }) + }) + + it('includes headers from the headers argument in the request', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/dataifles/123.json', { + 'if-modified-since': 'Fri, 08 Mar 2019 18:57:18 GMT', + }) + + expect(xhrs.length).toBe(1) + expect(xhrs[0].requestHeaders['if-modified-since']).toBe( + 'Fri, 08 Mar 2019 18:57:18 GMT', + ) + + xhrs[0].respond(404, {}, '') + + await req.responsePromise + }) + + it('includes headers from the response in the eventual response in the return value', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}) + + const xhr = xhrs[0] + xhr.respond( + 200, + { + 'content-type': 'application/json', + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, + '{"foo":"bar"}', + ) + + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'content-type': 'application/json', + 'last-modified': 'Fri, 08 Mar 2019 18:57:18 GMT', + }, + }) + }) + + it('returns a rejected promise when there is a request error', async () => { + const req = makeGetRequest('https://cdn.optimizely.com/datafiles/123.json', {}) + xhrs[0].error() + await expect(req.responsePromise).rejects.toThrow() + }) + }) +}) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index ad0b2c707..63ec6967f 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -33,8 +33,10 @@ "devDependencies": { "@types/jest": "^24.0.9", "@types/nock": "^9.3.1", + "@types/sinon": "^7.0.10", "jest": "^24.1.0", "nock": "^10.0.6", + "sinon": "^7.2.7", "ts-jest": "^24.0.0", "typescript": "^3.3.3333" }, diff --git a/packages/datafile-manager/src/browserDatafileManager.ts b/packages/datafile-manager/src/browserDatafileManager.ts new file mode 100644 index 000000000..e7c2f568c --- /dev/null +++ b/packages/datafile-manager/src/browserDatafileManager.ts @@ -0,0 +1,25 @@ +/** + * 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. + */ + +import { makeGetRequest } from './browserRequest' +import HttpPollingDatafileManager from './httpPollingDatafileManager' +import { Headers, AbortableRequest } from './http'; + +export default class BrowserDatafileManager extends HttpPollingDatafileManager { + protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + return makeGetRequest(reqUrl, headers) + } +} diff --git a/packages/datafile-manager/src/browserRequest.ts b/packages/datafile-manager/src/browserRequest.ts new file mode 100644 index 000000000..7d9fef620 --- /dev/null +++ b/packages/datafile-manager/src/browserRequest.ts @@ -0,0 +1,86 @@ +/** + * 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. + */ + +import { AbortableRequest, Response, Headers } from './http' + +const GET_METHOD = 'GET' +const READY_STATE_DONE = 4 + +function parseHeadersFromXhr(req: XMLHttpRequest): Headers { + const allHeadersString = req.getAllResponseHeaders() + + if (allHeadersString === null) { + return {} + } + + const headerLines = allHeadersString.split('\r\n') + const headers: Headers = {} + headerLines.forEach(headerLine => { + const separatorIndex = headerLine.indexOf(': ') + if (separatorIndex > -1) { + const headerName = headerLine.slice(0, separatorIndex) + const headerValue = headerLine.slice(separatorIndex + 2) + if (headerValue.length > 0) { + headers[headerName] = headerValue + } + } + }) + return headers +} + +function setHeadersInXhr(headers: Headers, req: XMLHttpRequest): void { + Object.keys(headers).forEach(headerName => { + const header = headers[headerName] + req.setRequestHeader(headerName, header!) + }) +} + +export function makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { + const req = new XMLHttpRequest() + + const responsePromise: Promise = new Promise((resolve, reject) => { + req.open(GET_METHOD, reqUrl, true) + + setHeadersInXhr(headers, req) + + req.onreadystatechange = () => { + if (req.readyState === READY_STATE_DONE) { + const statusCode = req.status + if (statusCode === 0) { + reject(new Error('Request error')) + return + } + + const headers = parseHeadersFromXhr(req) + const resp: Response = { + statusCode: req.status, + body: req.responseText, + headers, + } + resolve(resp) + } + } + + req.send() + }) + + return { + responsePromise, + abort() { + req.abort() + }, + } +} diff --git a/packages/datafile-manager/yarn.lock b/packages/datafile-manager/yarn.lock index 09dd3ed1d..a8dc08617 100644 --- a/packages/datafile-manager/yarn.lock +++ b/packages/datafile-manager/yarn.lock @@ -145,6 +145,35 @@ dependencies: uuid "^3.3.2" +"@sinonjs/commons@^1", "@sinonjs/commons@^1.0.2", "@sinonjs/commons@^1.3.1": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.4.0.tgz#7b3ec2d96af481d7a0321252e7b1c94724ec5a78" + integrity sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw== + dependencies: + type-detect "4.0.8" + +"@sinonjs/formatio@^3.1.0", "@sinonjs/formatio@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@sinonjs/formatio/-/formatio-3.2.1.tgz#52310f2f9bcbc67bdac18c94ad4901b95fde267e" + integrity sha512-tsHvOB24rvyvV2+zKMmPkZ7dXX6LSLKZ7aOtXY6Edklp0uRcgGpOsQTTGTcWViFyx4uhWc6GV8QdnALbIbIdeQ== + dependencies: + "@sinonjs/commons" "^1" + "@sinonjs/samsam" "^3.1.0" + +"@sinonjs/samsam@^3.1.0", "@sinonjs/samsam@^3.2.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-3.3.0.tgz#9557ea89cd39dbc94ffbd093c8085281cac87416" + integrity sha512-beHeJM/RRAaLLsMJhsCvHK31rIqZuobfPLa/80yGH5hnD8PV1hyh9xJBJNFfNmO7yWqm+zomijHsXpI6iTQJfQ== + dependencies: + "@sinonjs/commons" "^1.0.2" + array-from "^2.1.1" + lodash "^4.17.11" + +"@sinonjs/text-encoding@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz#8da5c6530915653f3a1f38fd5f101d8c3f8079c5" + integrity sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ== + "@types/jest-diff@*": version "20.0.1" resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" @@ -174,6 +203,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.5.tgz#fbaca34086bdc118011e1f05c47688d432f2d571" integrity sha512-DuIRlQbX4K+d5I+GMnv+UfnGh+ist0RdlvOp+JZ7ePJ6KQONCFQv/gKYSU1ZzbVdFSUCKZOltjmpFAGGv5MdYA== +"@types/sinon@^7.0.10": + version "7.0.10" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-7.0.10.tgz#1f921f0c347b19f754e61dbc671c088df73fe1ff" + integrity sha512-4w7SvsiUOtd4mUfund9QROPSJ5At/GQskDpqd87pJIRI6ULWSJqHI3GIZE337wQuN3aznroJGr94+o8fwvL37Q== + abab@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.0.tgz#aba0ab4c5eee2d4c79d3487d85450fb2376ebb0f" @@ -299,6 +333,11 @@ array-equal@^1.0.0: resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" integrity sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM= +array-from@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/array-from/-/array-from-2.1.1.tgz#cfe9d8c26628b9dc5aecc62a9f5d8f1f352c1195" + integrity sha1-z+nYwmYoudxa7MYqn12PHzUsEZU= + array-unique@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" @@ -802,6 +841,11 @@ diff-sequences@^24.0.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.0.0.tgz#cdf8e27ed20d8b8d3caccb4e0c0d8fe31a173013" integrity sha512-46OkIuVGBBnrC0soO/4LHu5LHGHx0uhP65OVz8XOrAJpqiCB2aVIuESvjI1F9oqebuvY8lekS1pt6TN7vt7qsw== +diff@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" + integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== + domexception@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" @@ -1455,6 +1499,11 @@ is-wsl@^1.1.0: resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -1974,6 +2023,11 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +just-extend@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" + integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -2056,6 +2110,16 @@ lodash@^4.17.11, lodash@^4.17.5: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== +lolex@^2.3.2: + version "2.7.5" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.5.tgz#113001d56bfc7e02d56e36291cc5c413d1aa0733" + integrity sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q== + +lolex@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-3.1.0.tgz#1a7feb2fefd75b3e3a7f79f0e110d9476e294434" + integrity sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw== + loose-envify@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -2261,6 +2325,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +nise@^1.4.10: + version "1.4.10" + resolved "https://registry.yarnpkg.com/nise/-/nise-1.4.10.tgz#ae46a09a26436fae91a38a60919356ae6db143b6" + integrity sha512-sa0RRbj53dovjc7wombHmVli9ZihXbXCQ2uH3TNm03DyvOSIQbxg+pbqDKrk2oxMK1rtLGVlKxcB9rrc6X5YjA== + dependencies: + "@sinonjs/formatio" "^3.1.0" + "@sinonjs/text-encoding" "^0.7.1" + just-extend "^4.0.2" + lolex "^2.3.2" + path-to-regexp "^1.7.0" + nock@^10.0.6: version "10.0.6" resolved "https://registry.yarnpkg.com/nock/-/nock-10.0.6.tgz#e6d90ee7a68b8cfc2ab7f6127e7d99aa7d13d111" @@ -2562,6 +2637,13 @@ path-parse@^1.0.6: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== +path-to-regexp@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" + integrity sha1-Wf3g9DW62suhA6hOnTvGTpa5k30= + dependencies: + isarray "0.0.1" + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -2931,6 +3013,19 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= +sinon@^7.2.7: + version "7.2.7" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-7.2.7.tgz#ee90f83ce87d9a6bac42cf32a3103d8c8b1bfb68" + integrity sha512-rlrre9F80pIQr3M36gOdoCEWzFAMDgHYD8+tocqOw+Zw9OZ8F84a80Ds69eZfcjnzDqqG88ulFld0oin/6rG/g== + dependencies: + "@sinonjs/commons" "^1.3.1" + "@sinonjs/formatio" "^3.2.1" + "@sinonjs/samsam" "^3.2.0" + diff "^3.5.0" + lolex "^3.1.0" + nise "^1.4.10" + supports-color "^5.5.0" + sisteransi@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.0.tgz#77d9622ff909080f1c19e5f4a1df0c1b0a27b88c" @@ -3144,7 +3239,7 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -supports-color@^5.3.0: +supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -3288,7 +3383,7 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@^4.0.0, type-detect@^4.0.5: +type-detect@4.0.8, type-detect@^4.0.0, type-detect@^4.0.5: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== From cec5eee51e717d730107014dc15e7e9f0d11d84d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 25 Mar 2019 13:56:56 -0700 Subject: [PATCH 07/42] feat (datafile manager): Increasing backoff delay after network errors or non-success response status (#7) Summary: Implement an increasing delay for the next request after a response Promise is rejected, or resolved with a non-successful status. If the configured updateInterval is greater than the calculated delay, we just use the updateInterval. Test plan: Added unit tests for new module backoffController. Added unit tests for httpPollingDatafileManager backoff functionality. --- .../__test__/backoffController.spec.ts | 57 ++++++++++++ .../httpPollingDatafileManager.spec.ts | 93 ++++++++++++++++++- .../datafile-manager/src/backoffController.ts | 49 ++++++++++ packages/datafile-manager/src/config.ts | 4 + .../src/httpPollingDatafileManager.ts | 48 +++++++--- 5 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 packages/datafile-manager/__test__/backoffController.spec.ts create mode 100644 packages/datafile-manager/src/backoffController.ts diff --git a/packages/datafile-manager/__test__/backoffController.spec.ts b/packages/datafile-manager/__test__/backoffController.spec.ts new file mode 100644 index 000000000..07e576b4f --- /dev/null +++ b/packages/datafile-manager/__test__/backoffController.spec.ts @@ -0,0 +1,57 @@ +/** + * 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. + */ + +import BackoffController from '../src/backoffController' + +describe('backoffController', () => { + describe('getDelay', () => { + it('returns 0 from getDelay if there have been no errors', () => { + const controller = new BackoffController() + expect(controller.getDelay()).toBe(0) + }) + + it('increases the delay returned from getDelay (up to a maximum value) after each call to countError', () => { + const controller = new BackoffController() + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(2000) + expect(controller.getDelay()).toBeLessThan(3000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(4000) + expect(controller.getDelay()).toBeLessThan(5000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(8000) + expect(controller.getDelay()).toBeLessThan(9000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(16000) + expect(controller.getDelay()).toBeLessThan(17000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(32000) + expect(controller.getDelay()).toBeLessThan(33000) + // Maximum reached - additional errors should not increase the delay further + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(32000) + expect(controller.getDelay()).toBeLessThan(33000) + }) + + it('resets the error count when reset is called', () => { + const controller = new BackoffController() + controller.countError() + expect(controller.getDelay()).toBeGreaterThan(0) + controller.reset() + expect(controller.getDelay()).toBe(0) + }) + }) +}) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 3d295ad2a..e5b7084fb 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -19,18 +19,33 @@ import { Headers, AbortableRequest, Response } from '../src/http' import { DatafileManagerConfig } from '../src/datafileManager'; import TestTimeoutFactory from './testTimeoutFactory' +jest.mock('../src/backoffController', () => { + return jest.fn().mockImplementation(() => { + const getDelayMock = jest.fn().mockImplementation(() => 0) + return { + getDelay: getDelayMock, + countError: jest.fn(), + reset: jest.fn(), + } + }) +}); + +import BackoffController from '../src/backoffController' + // Test implementation: // - Does not make any real requests: just resolves with queued responses (tests push onto queuedResponses) class TestDatafileManager extends HTTPPollingDatafileManager { - queuedResponses: Response[] = [] + queuedResponses: (Response | Error)[] = [] responsePromises: Promise[] = [] makeGetRequest(url: string, headers: Headers): AbortableRequest { - const nextResponse: Response | undefined = this.queuedResponses.pop() + const nextResponse: Error | Response | undefined = this.queuedResponses.pop() let responsePromise: Promise if (nextResponse === undefined) { responsePromise = Promise.reject('No responses queued') + } else if (nextResponse instanceof Error) { + responsePromise = Promise.reject(nextResponse) } else { responsePromise = Promise.resolve(nextResponse) } @@ -56,6 +71,7 @@ describe('httpPollingDatafileManager', () => { if (manager) { manager.stop() } + jest.clearAllMocks() jest.restoreAllMocks() }) @@ -321,6 +337,79 @@ describe('httpPollingDatafileManager', () => { }) }) }) + + describe('backoff', () => { + it('uses the delay from the backoff controller getDelay method when greater than updateInterval', async () => { + const BackoffControllerMock = (BackoffController as unknown) as jest.Mock + const getDelayMock = BackoffControllerMock.mock.results[0].value.getDelay + getDelayMock.mockImplementationOnce(() => 5432) + const setTimeoutSpy = jest.spyOn(testTimeoutFactory, 'setTimeout') + manager.queuedResponses.push( + { + statusCode: 404, + body: '', + headers: {} + } + ) + manager.start() + await manager.responsePromises[0] + expect(setTimeoutSpy).toBeCalledTimes(1) + expect(setTimeoutSpy.mock.calls[0][1]).toBe(5432) + }) + + it('calls countError on the backoff controller when a non-success status code response is received', async () => { + manager.queuedResponses.push( + { + statusCode: 404, + body: '', + headers: {} + } + ) + manager.start() + await manager.responsePromises[0] + const BackoffControllerMock = (BackoffController as unknown) as jest.Mock + expect(BackoffControllerMock.mock.results[0].value.countError).toBeCalledTimes(1) + }) + + it('calls countError on the backoff controller when the response promise rejects', async () => { + manager.queuedResponses.push(new Error('Connection failed')) + manager.start() + try { + await manager.responsePromises[0] + } catch (e) { + } + const BackoffControllerMock = (BackoffController as unknown) as jest.Mock + expect(BackoffControllerMock.mock.results[0].value.countError).toBeCalledTimes(1) + }) + + it('calls reset on the backoff controller when a success status code response is received', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: { + 'Last-Modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + } + ) + manager.start() + const BackoffControllerMock = (BackoffController as unknown) as jest.Mock + // Reset is called in start - we want to check that it is also called after the response, so reset the mock here + BackoffControllerMock.mock.results[0].value.reset.mockReset() + await manager.onReady() + expect(BackoffControllerMock.mock.results[0].value.reset).toBeCalledTimes(1) + }) + + it('resets the backoff controller when start is called', async () => { + const BackoffControllerMock = (BackoffController as unknown) as jest.Mock + manager.start() + expect(BackoffControllerMock.mock.results[0].value.reset).toBeCalledTimes(1) + try { + await manager.responsePromises[0] + } catch (e) { + } + }) + }) }) }) }) diff --git a/packages/datafile-manager/src/backoffController.ts b/packages/datafile-manager/src/backoffController.ts new file mode 100644 index 000000000..f08f583bd --- /dev/null +++ b/packages/datafile-manager/src/backoffController.ts @@ -0,0 +1,49 @@ +/** + * 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. + */ + +import { + BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT, + BACKOFF_MAX_ERROR_COUNT, +} from './config' + +function randomMilliseconds() { + return Math.round(Math.random() * 1000) +} + +export default class BackoffController { + private errorCount = 0 + + getDelay(): number { + if (this.errorCount === 0) { + return 0 + } + const baseWaitSeconds = + BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT[ + Math.min(BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1, this.errorCount) + ] + return baseWaitSeconds * 1000 + randomMilliseconds() + } + + countError(): void { + if (this.errorCount < BACKOFF_MAX_ERROR_COUNT) { + this.errorCount++ + } + } + + reset(): void { + this.errorCount = 0 + } +} diff --git a/packages/datafile-manager/src/config.ts b/packages/datafile-manager/src/config.ts index 7e57238cb..28959aeba 100644 --- a/packages/datafile-manager/src/config.ts +++ b/packages/datafile-manager/src/config.ts @@ -21,3 +21,7 @@ export const MIN_UPDATE_INTERVAL = 1 export const SDK_KEY_TOKEN = '$SDK_KEY' export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/${SDK_KEY_TOKEN}.json` + +export const BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT = [0, 2, 4, 8, 16, 32] + +export const BACKOFF_MAX_ERROR_COUNT = 10 diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 046e0ef2e..7773163fa 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -20,6 +20,7 @@ import EventEmitter from './eventEmitter' import { AbortableRequest, Response, Headers } from './http'; import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE, SDK_KEY_TOKEN } from './config' import { TimeoutFactory, DEFAULT_TIMEOUT_FACTORY } from './timeoutFactory' +import BackoffController from './backoffController'; const logger = getLogger('DatafileManager') @@ -29,6 +30,10 @@ function isValidUpdateInterval(updateInterval: number): boolean { return updateInterval >= MIN_UPDATE_INTERVAL } +function isSuccessStatusCode(statusCode: number): boolean { + return statusCode >= 200 && statusCode < 400 +} + export default abstract class HTTPPollingDatafileManager implements DatafileManager { // Make an HTTP get request to the given URL with the given headers // Return an AbortableRequest, which has a promise for a Response. @@ -66,6 +71,8 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private currentRequest?: AbortableRequest + private backoffController: BackoffController + constructor(config: DatafileManagerConfig) { const { datafile, @@ -108,6 +115,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana logger.warn('Invalid updateInterval %s, defaulting to %s', updateInterval, DEFAULT_UPDATE_INTERVAL) this.updateInterval = DEFAULT_UPDATE_INTERVAL } + this.backoffController = new BackoffController() } get(): string | null { @@ -118,6 +126,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana if (!this.isStarted) { logger.debug('Datafile manager started') this.isStarted = true + this.backoffController.reset() this.syncDatafile() } } @@ -152,11 +161,13 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana return this.urlTemplate.replace(SDK_KEY_TOKEN, sdkKey) } - private logMakeGetRequestError(err: any): void { + private onRequestRejected(err: any): void { if (!this.isStarted) { return } + this.backoffController.countError() + if (err instanceof Error) { logger.error('Error fetching datafile: %s', err.message, err) } else if (typeof err === 'string') { @@ -166,11 +177,18 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana } } - private tryUpdatingDatafile(response: Response): void { + private onRequestResolved(response: Response): void { if (!this.isStarted) { return } + if (typeof response.statusCode !== 'undefined' && + isSuccessStatusCode(response.statusCode)) { + this.backoffController.reset() + } else { + this.backoffController.countError() + } + this.trySavingLastModified(response.headers) const datafile = this.getNextDatafileFromResponse(response) @@ -188,7 +206,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana } } - private onFetchComplete(this: HTTPPollingDatafileManager): void { + private onRequestComplete(this: HTTPPollingDatafileManager): void { if (!this.isStarted) { return } @@ -215,18 +233,18 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana logger.debug('Making datafile request to url %s with headers: %s', datafileUrl, () => JSON.stringify(headers)) this.currentRequest = this.makeGetRequest(datafileUrl, headers) - const onFetchComplete = () => { - this.onFetchComplete() + const onRequestComplete = () => { + this.onRequestComplete() } - const tryUpdatingDatafile = (response: Response) => { - this.tryUpdatingDatafile(response) + const onRequestResolved = (response: Response) => { + this.onRequestResolved(response) } - const logMakeGetRequestError = (err: any) => { - this.logMakeGetRequestError(err) + const onRequestRejected = (err: any) => { + this.onRequestRejected(err) } this.currentRequest.responsePromise - .then(tryUpdatingDatafile, logMakeGetRequestError) - .then(onFetchComplete, onFetchComplete) + .then(onRequestResolved, onRequestRejected) + .then(onRequestComplete, onRequestComplete) } private resolveReadyPromise(): void { @@ -240,10 +258,12 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana } private scheduleNextUpdate(): void { - logger.debug('Scheduling sync in %s ms', this.updateInterval) + const currentBackoffDelay = this.backoffController.getDelay() + const nextUpdateDelay = Math.max(currentBackoffDelay, this.updateInterval) + logger.debug('Scheduling sync in %s ms', nextUpdateDelay) this.cancelTimeout = this.timeoutFactory.setTimeout(() => { this.syncDatafile() - }, this.updateInterval) + }, nextUpdateDelay) } private getNextDatafileFromResponse(response: Response): string | null { @@ -254,7 +274,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana if (response.statusCode === 304) { return null } - if (response.statusCode >= 200 && response.statusCode < 400) { + if (isSuccessStatusCode(response.statusCode)) { return response.body } return null From dcc0ed441d044bd692516270950ecd1595907792 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 25 Mar 2019 21:01:35 -0700 Subject: [PATCH 08/42] fix (datafile manager): Add several small fixes including top-level entry point (#8) Summary: This adds several small, but required, fixes: - Update README - Rename liveUpdates to autoUpdate - Increase minimum allowed update interval to 1000 ms - Increase default update interval to 5 minutes - Introduce abstract method for subclasses to provide default config. Use this for autoUpdate default true in node, false in browser - Add top-level entry points for node (index.node.ts) and browser (index.browser.ts) - package.json fixes: Fix license, move @types/node to dev dependencies, add build script, add browser/node entry points - Remove unused DatafileManagerConfig interface properties - Add missing license header in some files Test plan: Updated affected unit tests --- packages/datafile-manager/README.md | 22 ++++++++++++-- .../__test__/browserDatafileManager.spec.ts | 27 +++++++++++++++-- .../httpPollingDatafileManager.spec.ts | 16 ++++++---- .../__test__/nodeDatafileManager.spec.ts | 29 +++++++++++++++++-- packages/datafile-manager/package.json | 15 ++++++---- .../src/browserDatafileManager.ts | 7 +++++ packages/datafile-manager/src/config.ts | 4 +-- .../datafile-manager/src/datafileManager.ts | 4 +-- .../src/httpPollingDatafileManager.ts | 21 +++++++++----- .../datafile-manager/src/index.browser.ts | 18 ++++++++++++ packages/datafile-manager/src/index.node.ts | 18 ++++++++++++ .../src/nodeDatafileManager.ts | 7 +++++ .../datafile-manager/src/timeoutFactory.ts | 16 ++++++++++ packages/datafile-manager/yarn.lock | 8 ++--- 14 files changed, 179 insertions(+), 33 deletions(-) create mode 100644 packages/datafile-manager/src/index.browser.ts create mode 100644 packages/datafile-manager/src/index.node.ts diff --git a/packages/datafile-manager/README.md b/packages/datafile-manager/README.md index dabba157f..9542b197d 100644 --- a/packages/datafile-manager/README.md +++ b/packages/datafile-manager/README.md @@ -1,6 +1,6 @@ # Javascript SDK Datafile Manager -TODO: Write this +This package provides a datafile manager implementations for Node.js and the browser. ## Installation @@ -10,4 +10,22 @@ npm install @optimizely/datafile-manager ## Usage -TODO +```js +const { DatafileManager } = require('@optimizely/datafile-manager') + +const manager = new DatafileManager({ + sdkKey: '9LCprAQyd1bs1BBXZ3nVji', + autoUpdate: true, + updateInterval: 5000, +}) +manager.start() +manager.onReady().then(() => { + const datafile = manager.get() + console.log('Manager is ready with datafile: ') + console.log(datafile) +}) +manager.on('update', ({ datafile }) => { + console.log('New datafile available: ') + console.log(datafile) +}) +``` diff --git a/packages/datafile-manager/__test__/browserDatafileManager.spec.ts b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts index f162d1dcf..5f2b2492f 100644 --- a/packages/datafile-manager/__test__/browserDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts @@ -44,7 +44,7 @@ describe('browserDatafileManager', () => { const manager = new BrowserDatafileManager({ sdkKey: '1234', - liveUpdates: false, + autoUpdate: false, }) manager.start() expect(makeGetRequestSpy).toBeCalledTimes(1) @@ -68,7 +68,7 @@ describe('browserDatafileManager', () => { }) const manager = new BrowserDatafileManager({ sdkKey: '1234', - liveUpdates: true, + autoUpdate: true, timeoutFactory: testTimeoutFactory, }) manager.start() @@ -82,4 +82,27 @@ describe('browserDatafileManager', () => { await manager.stop() }) + + it('defaults to false for autoUpdate', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + }) + }) + const manager = new BrowserDatafileManager({ + sdkKey: '1234', + timeoutFactory: testTimeoutFactory, + }) + manager.start() + await manager.onReady() + // Should not set a timeout for a later update + expect(testTimeoutFactory.timeoutFns.length).toBe(0) + + await manager.stop() + }) }) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index e5b7084fb..3a33fd8a7 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -52,6 +52,10 @@ class TestDatafileManager extends HTTPPollingDatafileManager { this.responsePromises.push(responsePromise) return { responsePromise, abort: jest.fn() } } + + getConfigDefaults(): Partial { + return {} + } } describe('httpPollingDatafileManager', () => { @@ -96,9 +100,9 @@ describe('httpPollingDatafileManager', () => { }) }) - describe('when constructed with sdkKey only', () => { + describe('when constructed with sdkKey and autoUpdate: true', () => { beforeEach(() => { - manager = createTestManager({ sdkKey: '123', updateInterval: 10 }) + manager = createTestManager({ sdkKey: '123', updateInterval: 1000, autoUpdate: true }) }) describe('initial state', () => { @@ -145,7 +149,7 @@ describe('httpPollingDatafileManager', () => { manager.start() await manager.onReady() expect(setTimeoutSpy).toBeCalledTimes(1) - expect(setTimeoutSpy.mock.calls[0][1]).toBe(10) + expect(setTimeoutSpy.mock.calls[0][1]).toBe(1000) }) it('emits update events after live updates', async () => { @@ -414,9 +418,9 @@ describe('httpPollingDatafileManager', () => { }) }) - describe('when constructed with sdkKey and liveUpdates: false', () => { + describe('when constructed with sdkKey and autoUpdate: false', () => { beforeEach(() => { - manager = createTestManager({ sdkKey: '123', liveUpdates: false }) + manager = createTestManager({ sdkKey: '123', autoUpdate: false }) }) it('after being started, fetches the datafile and resolves onReady', async () => { @@ -466,7 +470,7 @@ describe('httpPollingDatafileManager', () => { beforeEach(() => { manager = createTestManager({ sdkKey: '456', - updateInterval: 10, + updateInterval: 1000, urlTemplate: 'https://localhost:5556/datafiles/$SDK_KEY', }) }) diff --git a/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts index 9049f1dce..2f561cf2f 100644 --- a/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts @@ -44,7 +44,7 @@ describe('nodeDatafileManager', () => { const manager = new NodeDatafileManager({ sdkKey: '1234', - liveUpdates: false, + autoUpdate: false, }) manager.start() expect(makeGetRequestSpy).toBeCalledTimes(1) @@ -68,7 +68,7 @@ describe('nodeDatafileManager', () => { }) const manager = new NodeDatafileManager({ sdkKey: '1234', - liveUpdates: true, + autoUpdate: true, timeoutFactory: testTimeoutFactory, }) manager.start() @@ -82,4 +82,29 @@ describe('nodeDatafileManager', () => { await manager.stop() }) + + it('defaults to true for autoUpdate', async () => { + makeGetRequestSpy.mockReturnValue({ + abort: jest.fn(), + responsePromise: Promise.resolve({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: { + 'last-modified': 'Fri, 08 Mar 2019 18:57:17 GMT', + }, + }) + }) + const manager = new NodeDatafileManager({ + sdkKey: '1234', + timeoutFactory: testTimeoutFactory, + }) + manager.start() + await manager.onReady() + // Should set a timeout for a later update + expect(testTimeoutFactory.timeoutFns.length).toBe(1) + testTimeoutFactory.timeoutFns[0]() + expect(makeGetRequestSpy).toBeCalledTimes(2) + + await manager.stop() + }) }) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index 63ec6967f..ffd6f8ba5 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -3,8 +3,12 @@ "version": "0.1.0", "description": "Optimizely Full Stack Datafile Manager", "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/datafile-manager", - "license": "MIT", - "main": "lib/index.js", + "license": "Apache-2.0", + "engines": { + "node": ">=4.0.0" + }, + "main": "lib/index.node.js", + "browser": "lib/index.browser.js", "types": "lib/index.d.ts", "directories": { "lib": "lib", @@ -33,6 +37,7 @@ "devDependencies": { "@types/jest": "^24.0.9", "@types/nock": "^9.3.1", + "@types/node": "^11.11.7", "@types/sinon": "^7.0.10", "jest": "^24.1.0", "nock": "^10.0.6", @@ -41,10 +46,10 @@ "typescript": "^3.3.3333" }, "dependencies": { - "@optimizely/js-sdk-logging": "^0.1.0", - "@types/node": "^11.10.5" + "@optimizely/js-sdk-logging": "^0.1.0" }, "scripts": { - "test": "jest" + "test": "jest", + "build": "rm -r lib && tsc" } } diff --git a/packages/datafile-manager/src/browserDatafileManager.ts b/packages/datafile-manager/src/browserDatafileManager.ts index e7c2f568c..3c23f059a 100644 --- a/packages/datafile-manager/src/browserDatafileManager.ts +++ b/packages/datafile-manager/src/browserDatafileManager.ts @@ -17,9 +17,16 @@ import { makeGetRequest } from './browserRequest' import HttpPollingDatafileManager from './httpPollingDatafileManager' import { Headers, AbortableRequest } from './http'; +import { DatafileManagerConfig } from './datafileManager'; export default class BrowserDatafileManager extends HttpPollingDatafileManager { protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { return makeGetRequest(reqUrl, headers) } + + protected getConfigDefaults(): Partial { + return { + autoUpdate: false, + } + } } diff --git a/packages/datafile-manager/src/config.ts b/packages/datafile-manager/src/config.ts index 28959aeba..0dfc2c4d6 100644 --- a/packages/datafile-manager/src/config.ts +++ b/packages/datafile-manager/src/config.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -export const DEFAULT_UPDATE_INTERVAL = 5000 +export const DEFAULT_UPDATE_INTERVAL = 5 * 60 * 1000 // 5 minutes -export const MIN_UPDATE_INTERVAL = 1 +export const MIN_UPDATE_INTERVAL = 1000 export const SDK_KEY_TOKEN = '$SDK_KEY' diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index 3e6b02dcc..472d48c3b 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -45,10 +45,8 @@ export enum CacheDirective { } export interface DatafileManagerConfig { - cacheDirective?: CacheDirective + autoUpdate?: boolean datafile?: string - liveUpdates?: boolean - maxCacheAge?: number sdkKey: string timeoutFactory?: TimeoutFactory, updateInterval?: number diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 7773163fa..e40aff474 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -41,6 +41,9 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana // The request will be aborted if the manager is stopped while the request is in flight. protected abstract makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest + // Return any default configuration options that should be applied + protected abstract getConfigDefaults(): Partial + private currentDatafile: string | null private readonly sdkKey: string @@ -55,7 +58,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private readonly emitter: EventEmitter - private readonly liveUpdates: boolean + private readonly autoUpdate: boolean private readonly updateInterval: number @@ -74,14 +77,18 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private backoffController: BackoffController constructor(config: DatafileManagerConfig) { + const configWithDefaultsApplied: DatafileManagerConfig = { + ...this.getConfigDefaults(), + ...config + } const { datafile, - liveUpdates = true, + autoUpdate = false, sdkKey, timeoutFactory = DEFAULT_TIMEOUT_FACTORY, updateInterval = DEFAULT_UPDATE_INTERVAL, urlTemplate = DEFAULT_URL_TEMPLATE, - } = config + } = configWithDefaultsApplied this.sdkKey = sdkKey @@ -108,7 +115,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.timeoutFactory = timeoutFactory this.emitter = new EventEmitter() - this.liveUpdates = liveUpdates + this.autoUpdate = autoUpdate if (isValidUpdateInterval(updateInterval)) { this.updateInterval = updateInterval } else { @@ -197,7 +204,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.currentDatafile = datafile if (!this.isReadyPromiseSettled) { this.resolveReadyPromise() - } else if (this.liveUpdates) { + } else if (this.autoUpdate) { const datafileUpdate: DatafileUpdate = { datafile, } @@ -213,10 +220,10 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.currentRequest = undefined - if (this.liveUpdates) { + if (this.autoUpdate) { this.scheduleNextUpdate() } - if (!this.isReadyPromiseSettled && !this.liveUpdates) { + if (!this.isReadyPromiseSettled && !this.autoUpdate) { // We will never resolve ready, so reject it this.rejectReadyPromise(new Error('Failed to become ready')) } diff --git a/packages/datafile-manager/src/index.browser.ts b/packages/datafile-manager/src/index.browser.ts new file mode 100644 index 000000000..0b0a81ba9 --- /dev/null +++ b/packages/datafile-manager/src/index.browser.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export * from './datafileManager' +export { default as DatafileManager } from './browserDatafileManager' diff --git a/packages/datafile-manager/src/index.node.ts b/packages/datafile-manager/src/index.node.ts new file mode 100644 index 000000000..8c520e868 --- /dev/null +++ b/packages/datafile-manager/src/index.node.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export * from './datafileManager' +export { default as DatafileManager } from './nodeDatafileManager' diff --git a/packages/datafile-manager/src/nodeDatafileManager.ts b/packages/datafile-manager/src/nodeDatafileManager.ts index 6fa0f618c..6672bc2dc 100644 --- a/packages/datafile-manager/src/nodeDatafileManager.ts +++ b/packages/datafile-manager/src/nodeDatafileManager.ts @@ -17,9 +17,16 @@ import { makeGetRequest } from './nodeRequest' import HttpPollingDatafileManager from './httpPollingDatafileManager' import { Headers, AbortableRequest } from './http'; +import { DatafileManagerConfig } from './datafileManager'; export default class NodeDatafileManager extends HttpPollingDatafileManager { protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest { return makeGetRequest(reqUrl, headers) } + + protected getConfigDefaults(): Partial { + return { + autoUpdate: true, + } + } } diff --git a/packages/datafile-manager/src/timeoutFactory.ts b/packages/datafile-manager/src/timeoutFactory.ts index c68ed22f6..a9dff614f 100644 --- a/packages/datafile-manager/src/timeoutFactory.ts +++ b/packages/datafile-manager/src/timeoutFactory.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + export interface TimeoutFactory { setTimeout(onTimeout: () => void, timeout: number): () => void } diff --git a/packages/datafile-manager/yarn.lock b/packages/datafile-manager/yarn.lock index a8dc08617..8f434abd1 100644 --- a/packages/datafile-manager/yarn.lock +++ b/packages/datafile-manager/yarn.lock @@ -198,10 +198,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.3.tgz#7c6b0f8eaf16ae530795de2ad1b85d34bf2f5c58" integrity sha512-wp6IOGu1lxsfnrD+5mX6qwSwWuqsdkKKxTN4aQc4wByHAKZJf9/D4KXPQ1POUjEbnCP5LMggB0OEFNY9OTsMqg== -"@types/node@^11.10.5": - version "11.10.5" - resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.5.tgz#fbaca34086bdc118011e1f05c47688d432f2d571" - integrity sha512-DuIRlQbX4K+d5I+GMnv+UfnGh+ist0RdlvOp+JZ7ePJ6KQONCFQv/gKYSU1ZzbVdFSUCKZOltjmpFAGGv5MdYA== +"@types/node@^11.11.7": + version "11.11.7" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.11.7.tgz#f1c35a906b82adae76ede5ab0d2088e58fa37843" + integrity sha512-bHbRcyD6XpXVLg42QYaQCjvDXaCFkvb3WbCIxSDmhGbJYVroxvYzekk9QGg1beeIawfvSLkdZpP0h7jxE4ihnA== "@types/sinon@^7.0.10": version "7.0.10" From 5766c1a87e843a9ca49eea04946876ebf703bb39 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 27 Mar 2019 10:11:23 -0700 Subject: [PATCH 09/42] feat(datafile manager): Update datafile manager interface to work with objects instead of strings. Add StaticDatafileManager. (#9) Summary: - DatafileManager interface uses objects instead of strings - HttpPollingDatafileManager uses JSON.parse on response bodies - Added StaticDatafileManager as a top level export (will be used in optimizely-sdk) Test plan: Updated unit tests & added new unit tests. Issues: https://optimizely.atlassian.net/browse/OASIS-4384 --- .../httpPollingDatafileManager.spec.ts | 50 +++++++++++++------ .../__test__/staticDatafileManager.spec.ts | 30 +++++++++++ packages/datafile-manager/package.json | 4 +- .../datafile-manager/src/datafileManager.ts | 6 +-- .../src/httpPollingDatafileManager.ts | 25 ++++++++-- .../datafile-manager/src/index.browser.ts | 1 + packages/datafile-manager/src/index.node.ts | 1 + .../src/staticDatafileManager.ts | 33 ++++++++++++ 8 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 packages/datafile-manager/__test__/staticDatafileManager.spec.ts create mode 100644 packages/datafile-manager/src/staticDatafileManager.ts diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 3a33fd8a7..9c18c3e4f 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -81,11 +81,11 @@ describe('httpPollingDatafileManager', () => { describe('when constructed with sdkKey and datafile', () => { beforeEach(() => { - manager = createTestManager({ datafile: 'abcd', sdkKey: '123' }) + manager = createTestManager({ datafile: { foo: 'abcd' }, sdkKey: '123' }) }) it('returns the passed datafile from get', () => { - expect(manager.get()).toBe('abcd') + expect(manager.get()).toEqual({ foo: 'abcd' }) }) it('after being started, fetches the datafile and resolves onReady', async () => { @@ -96,7 +96,7 @@ describe('httpPollingDatafileManager', () => { }) manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) }) }) @@ -133,7 +133,27 @@ describe('httpPollingDatafileManager', () => { }) manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) + }) + + it('does not update if the response body is not valid json', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"foo" "', + headers: {}, + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {}, + }, + ) + manager.start() + await manager.onReady() + testTimeoutFactory.timeoutFns[0]() + await manager.responsePromises[1] + expect(manager.get()).toEqual({ foo: 'bar' }) }) describe('live updates', () => { @@ -176,22 +196,22 @@ describe('httpPollingDatafileManager', () => { manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) expect(updateFn).toBeCalledTimes(0) testTimeoutFactory.timeoutFns[0]() await manager.responsePromises[1] expect(updateFn).toBeCalledTimes(1) - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo2": "bar2"}' }) - expect(manager.get()).toBe('{"foo2": "bar2"}') + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: { foo2: 'bar2' } }) + expect(manager.get()).toEqual({ foo2: 'bar2' }) updateFn.mockReset() testTimeoutFactory.timeoutFns[1]() await manager.responsePromises[2] expect(updateFn).toBeCalledTimes(1) - expect(updateFn.mock.calls[0][0]).toEqual({ datafile: '{"foo3": "bar3"}' }) - expect(manager.get()).toBe('{"foo3": "bar3"}') + expect(updateFn.mock.calls[0][0]).toEqual({ datafile: { foo3: 'bar3' } }) + expect(manager.get()).toEqual({ foo3: 'bar3' }) }) it('cancels a pending timeout when stop is called', async () => { @@ -228,13 +248,13 @@ describe('httpPollingDatafileManager', () => { manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) testTimeoutFactory.timeoutFns[0]() expect(manager.responsePromises.length).toBe(2) manager.stop() await manager.responsePromises[1] // Should not have updated datafile since manager was stopped - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) }) it('calls abort on the current request if there is a current request when stop is called', async () => { @@ -276,7 +296,7 @@ describe('httpPollingDatafileManager', () => { // Trigger the update, should fetch the next response which should succeed, then we get ready testTimeoutFactory.timeoutFns[0]() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) }) describe('newness checking', () => { @@ -301,7 +321,7 @@ describe('httpPollingDatafileManager', () => { manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) // First response promise was for the initial 200 response expect(manager.responsePromises.length).toBe(1) // Trigger the queued update @@ -311,7 +331,7 @@ describe('httpPollingDatafileManager', () => { await manager.responsePromises[1] // Since the response was 304, updateFn should not have been called expect(updateFn).toBeCalledTimes(0) - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) }) it('sends if-modified-since using the last observed response last-modified', async () => { @@ -431,7 +451,7 @@ describe('httpPollingDatafileManager', () => { }) manager.start() await manager.onReady() - expect(manager.get()).toBe('{"foo": "bar"}') + expect(manager.get()).toEqual({ foo: 'bar' }) }) it('does not schedule a live update after ready', async () => { diff --git a/packages/datafile-manager/__test__/staticDatafileManager.spec.ts b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts new file mode 100644 index 000000000..afc9bc365 --- /dev/null +++ b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts @@ -0,0 +1,30 @@ +import StaticDatafileManager from '../src/staticDatafileManager' + +describe('staticDatafileManager', () => { + it('can be constructed with a datafile object and become ready', async () => { + const manager = new StaticDatafileManager({ foo: 'bar' }) + manager.start() + await manager.onReady() + }) + + it('returns the datafile it was constructed with from get', async () => { + const manager = new StaticDatafileManager({ foo: 'bar' }) + manager.start() + expect(manager.get()).toEqual({ foo: 'bar' }) + await manager.onReady() + expect(manager.get()).toEqual({ foo: 'bar' }) + }) + + it('can be stopped', async () => { + const manager = new StaticDatafileManager({ foo: 'bar' }) + manager.start() + await manager.onReady() + await manager.stop() + }) + + it('can have event listeners added', () => { + const manager = new StaticDatafileManager({ foo: 'bar' }) + const dispose = manager.on('update', jest.fn()) + dispose() + }) +}) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index ffd6f8ba5..fb91703ec 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -1,5 +1,5 @@ { - "name": "datafile-manager", + "name": "@optimizely/js-sdk-datafile-manager", "version": "0.1.0", "description": "Optimizely Full Stack Datafile Manager", "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/datafile-manager", @@ -50,6 +50,6 @@ }, "scripts": { "test": "jest", - "build": "rm -r lib && tsc" + "tsc": "rm -rf lib && tsc" } } diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index 472d48c3b..40b21857b 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -17,7 +17,7 @@ import { TimeoutFactory } from "./timeoutFactory"; */ export interface DatafileUpdate { - datafile: string + datafile: object } export interface DatafileUpdateListener { @@ -32,7 +32,7 @@ interface Managed { } export interface DatafileManager extends Managed { - get: () => string | null + get: () => object | null on: (eventName: string, listener: DatafileUpdateListener) => () => void onReady: () => Promise } @@ -46,7 +46,7 @@ export enum CacheDirective { export interface DatafileManagerConfig { autoUpdate?: boolean - datafile?: string + datafile?: object sdkKey: string timeoutFactory?: TimeoutFactory, updateInterval?: number diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index e40aff474..1708f46bd 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -44,7 +44,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana // Return any default configuration options that should be applied protected abstract getConfigDefaults(): Partial - private currentDatafile: string | null + private currentDatafile: object | null private readonly sdkKey: string @@ -125,7 +125,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.backoffController = new BackoffController() } - get(): string | null { + get(): object | null { return this.currentDatafile } @@ -273,7 +273,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana }, nextUpdateDelay) } - private getNextDatafileFromResponse(response: Response): string | null { + private getNextDatafileFromResponse(response: Response): object | null { logger.debug('Response status code: %s', response.statusCode) if (typeof response.statusCode === 'undefined') { return null @@ -282,11 +282,28 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana return null } if (isSuccessStatusCode(response.statusCode)) { - return response.body + return this.tryParsingBodyAsJSON(response.body); } return null } + private tryParsingBodyAsJSON(body: string): object | null { + let parseResult: any + try { + parseResult = JSON.parse(body) + } catch (err) { + logger.error('Error parsing response body: %s', err.message, err) + return null + } + let datafileObj: object | null = null + if (typeof parseResult === 'object' && parseResult !== null) { + datafileObj = parseResult + } else { + logger.error('Error parsing response body: was not an object') + } + return datafileObj + } + private trySavingLastModified(headers: Headers): void { const lastModifiedHeader = headers['last-modified'] || headers['Last-Modified'] if (typeof lastModifiedHeader !== 'undefined') { diff --git a/packages/datafile-manager/src/index.browser.ts b/packages/datafile-manager/src/index.browser.ts index 0b0a81ba9..f83d01a9b 100644 --- a/packages/datafile-manager/src/index.browser.ts +++ b/packages/datafile-manager/src/index.browser.ts @@ -16,3 +16,4 @@ export * from './datafileManager' export { default as DatafileManager } from './browserDatafileManager' +export { default as StaticDatafileManager } from './staticDatafileManager'; diff --git a/packages/datafile-manager/src/index.node.ts b/packages/datafile-manager/src/index.node.ts index 8c520e868..e53dbbd18 100644 --- a/packages/datafile-manager/src/index.node.ts +++ b/packages/datafile-manager/src/index.node.ts @@ -16,3 +16,4 @@ export * from './datafileManager' export { default as DatafileManager } from './nodeDatafileManager' +export { default as StaticDatafileManager } from './staticDatafileManager'; diff --git a/packages/datafile-manager/src/staticDatafileManager.ts b/packages/datafile-manager/src/staticDatafileManager.ts new file mode 100644 index 000000000..5365b6951 --- /dev/null +++ b/packages/datafile-manager/src/staticDatafileManager.ts @@ -0,0 +1,33 @@ +import { DatafileManager, DatafileUpdate } from './datafileManager'; + +const doNothing = () => {}; + +export default class StaticDatafileManager implements DatafileManager { + private readonly datafile: object | null + + private readyPromise: Promise + + constructor(datafile: object | null) { + this.datafile = datafile + this.readyPromise = Promise.resolve(); + } + + get() { + return this.datafile + } + + onReady() { + return this.readyPromise + } + + start() { + } + + stop() { + return Promise.resolve(); + } + + on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void) { + return doNothing + } +} From 36d5c5543fd337e3fd618dac3728263a6cc78751 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 27 Mar 2019 21:21:44 -0700 Subject: [PATCH 10/42] (fix) Fix travis build (#10) --- .travis.yml | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/.travis.yml b/.travis.yml index 76aa52e41..832f0ed32 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,44 +4,9 @@ node_js: - '8' - '9' - '10' -branches: - only: - - master - - /^\d+\.\d+\.(\d|[x]) -env: - global: - # BROWSER_STACK_USERNAME - - secure: WnyM4gMsi2n69O/YZUD/pYwHJXdKDcBv3Hwft2cCw52yYc+z75uuRgdaLKs4BPisckBtnR17dH7hKlPX3HWwjCoqQm1q5qNpbJrArWaEcbotWGF2YFy21ZZ4rKNQmJqdgRj6XFZhLHbncA8v2gQPK7F6GUJ0vsJF/kiTfxAUjefR23oorcKSQrh9BfOxNAYu2Ma92qlaaMmHYbBdlNDM45/EQE+LnPCfboCiJD/5zTYq4Q+XhKLPV01vUDU60pj9ckDNXyLj9X2BwMbAzGAPGE4qTAB/IrMndVsUXblsahtwKQ6yrsUrsdTASz8/3oNkImtY7fqU874jSeG3d7PNBfZs47zkXEVy73ZWNBgM9rzVS5cPaIU3wqpuBoXFntDJcdHQhNTWEYdxmtcTUmxKt5TdUzDhrrkcti2WVLabU3N52aOBeOM0XBpfLkbV+HT6oWi3bNUb+EDMHvCxOxsP4IoEDfFs9HMzNIO3mmC3+2DFbI7s2Mb2oacAut38MbJDYSDTOLL4smG8scA3E0RQO4r8+TNk4aRIMQc7vCKqz7PpbO7Aj9dXSpeHrDmIszSmEoQqmaaGsRBwbXRom2P8fB9FcTbd/wbsfgoFNEPz5DlbtCtCmt0pQMa+3myWveKH52WC5KlFijBSDjYOMUnXbLnj5fK5eKaWp+z6/qcNwU8= - # BROWSER_STACK_ACCESS_KEY - - secure: U0GGZw46rJowBtH9gVluIrerB40u2b3uZpH0HsOdLlsXCCaTVk4JXX/JPVPashWAFLC7Enk3UOE4ofeEpVd0wbG6CxtG9/gklc2U2tvkqsdPpFZKaRrXoUzCyyPOmHEC2mXDXctbrncmttM4APaceRfbdTBEZIIfyLJadomjWylA61szFE9IZjvJpiwJO2xa5HI9GVRu3yXJci+riJux+JsDmfJ1hNwv3waMeeg/scddUH0hfgq69ftGs8cpMlYiO20eh32S7uPF7/IJTH1fDJjVKYQZwpypkF6AeI+od5CFTY1ajb25eaBNXThLS0Bo9ZJE/8Sogvon21dEJkt/ClY6R341InbAFXZvz7jyQAisvh0I4zxcu0VUCfh7bEUl6GXMO8VJnyxHEfqB+AIT2RoMXckkhulwiNUsJYH1yJ8mjnLvZq85mWBCp4n4jg0K6Wf46lHpjnHOVpLyLyoFGfiPf90AQVL02AJ3/ia8RkMuj0Ax+AGtiTC/+wy7dsDQOif/VpBNJcx/RciQ24mYOGzAMh4GsUWnXaZ9vXSxliogVNrmIefK5invJ0omv9pIx8NZHTHYGaulh4w6JsliiEq2kH78SlyvSrcsFGTwCY97LLaxiLm/75/Zf+F7LajKC23Fbtnj/LQizitFZqGMJ09DnR52krBAeultqRq8QLM= before_install: cd packages/optimizely-sdk install: npm install addons: srcclr: true script: npm test after_success: npm run coveralls - -# Integration tests need to run first to reset the PR build status to pending -stages: - - 'Integration tests' - - 'Cross-browser and umd unit tests' - - 'Test' - -jobs: - include: - - stage: 'Integration tests' - merge_mode: replace - env: SDK=javascript - cache: false - language: python - before_install: skip - install: - - "pip install awscli" - before_script: - - "aws s3 cp s3://optimizely-travisci-artifacts/ci/trigger_fullstack-sdk-compat.sh ci/ && chmod u+x ci/trigger_fullstack-sdk-compat.sh" - script: - - "ci/trigger_fullstack-sdk-compat.sh" - after_success: travis_terminate 0 - - stage: Cross-browser and umd unit tests - node_js: '8' - script: npm run test-ci From 0ef64db7cd93c4223f6f0985265985d6655ec0e3 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 1 Apr 2019 11:05:30 -0700 Subject: [PATCH 11/42] Add missing license headers. Move import statement under license header --- .../__test__/eventEmitter.spec.ts | 16 ++++++++++++++++ .../__test__/staticDatafileManager.spec.ts | 16 ++++++++++++++++ .../__test__/testTimeoutFactory.ts | 16 ++++++++++++++++ packages/datafile-manager/src/datafileManager.ts | 4 ++-- .../src/staticDatafileManager.ts | 16 ++++++++++++++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/packages/datafile-manager/__test__/eventEmitter.spec.ts b/packages/datafile-manager/__test__/eventEmitter.spec.ts index acfa96f1f..b7d393b49 100644 --- a/packages/datafile-manager/__test__/eventEmitter.spec.ts +++ b/packages/datafile-manager/__test__/eventEmitter.spec.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + import EventEmitter from '../src/eventEmitter' describe('event_emitter', () => { diff --git a/packages/datafile-manager/__test__/staticDatafileManager.spec.ts b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts index afc9bc365..8ceb3c52b 100644 --- a/packages/datafile-manager/__test__/staticDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + import StaticDatafileManager from '../src/staticDatafileManager' describe('staticDatafileManager', () => { diff --git a/packages/datafile-manager/__test__/testTimeoutFactory.ts b/packages/datafile-manager/__test__/testTimeoutFactory.ts index c4560064b..7b1db3667 100644 --- a/packages/datafile-manager/__test__/testTimeoutFactory.ts +++ b/packages/datafile-manager/__test__/testTimeoutFactory.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + import { TimeoutFactory } from '../src/timeoutFactory' export default class TestTimeoutFactory implements TimeoutFactory { diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index 40b21857b..49503479a 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -1,5 +1,3 @@ -import { TimeoutFactory } from "./timeoutFactory"; - /** * Copyright 2019, Optimizely * @@ -16,6 +14,8 @@ import { TimeoutFactory } from "./timeoutFactory"; * limitations under the License. */ +import { TimeoutFactory } from "./timeoutFactory"; + export interface DatafileUpdate { datafile: object } diff --git a/packages/datafile-manager/src/staticDatafileManager.ts b/packages/datafile-manager/src/staticDatafileManager.ts index 5365b6951..96e0674df 100644 --- a/packages/datafile-manager/src/staticDatafileManager.ts +++ b/packages/datafile-manager/src/staticDatafileManager.ts @@ -1,3 +1,19 @@ +/** + * 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. + */ + import { DatafileManager, DatafileUpdate } from './datafileManager'; const doNothing = () => {}; From d0b3b7707a0697551ccf66b87912b0a8aea2c0e4 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 1 Apr 2019 11:26:10 -0700 Subject: [PATCH 12/42] Add prepublishOnly script, and remove references to public SDK repo from package.json for initial publish --- packages/datafile-manager/package.json | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index fb91703ec..39455a783 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -2,7 +2,6 @@ "name": "@optimizely/js-sdk-datafile-manager", "version": "0.1.0", "description": "Optimizely Full Stack Datafile Manager", - "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/datafile-manager", "license": "Apache-2.0", "engines": { "node": ">=4.0.0" @@ -21,16 +20,6 @@ "README.md", "package.json" ], - "repository": { - "type": "git", - "url": "git+https://github.com/optimizely/javascript-sdk.git" - }, - "keywords": [ - "optimizely" - ], - "bugs": { - "url": "https://github.com/optimizely/javascript-sdk/issues" - }, "publishConfig": { "access": "public" }, @@ -50,6 +39,7 @@ }, "scripts": { "test": "jest", - "tsc": "rm -rf lib && tsc" + "tsc": "rm -rf lib && tsc", + "prepublishOnly": "yarn test && yarn tsc" } } From 4dcb5c415201da31981de06f89e9f50144831402 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 1 Apr 2019 20:37:09 -0700 Subject: [PATCH 13/42] fix (datafile manager): Change onReady meaning to 'a datafile is available' (#14) Summary: This changes the behavior of the `onReady` Promise to align with the Java implementation. The `onReady` Promise is now resolved as soon as the datafile manager has any datafile (whether it was passed in or fetched). An update event is always emitted when the datafile changes after `onReady`. Test plan: Updated unit tests --- .../httpPollingDatafileManager.spec.ts | 72 +++++++++++++++++-- .../src/httpPollingDatafileManager.ts | 15 ++-- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 9c18c3e4f..955a83715 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -79,24 +79,88 @@ describe('httpPollingDatafileManager', () => { jest.restoreAllMocks() }) - describe('when constructed with sdkKey and datafile', () => { + describe('when constructed with sdkKey and datafile and autoUpdate: true,', () => { beforeEach(() => { - manager = createTestManager({ datafile: { foo: 'abcd' }, sdkKey: '123' }) + manager = createTestManager({ datafile: { foo: 'abcd' }, sdkKey: '123', autoUpdate: true }) }) it('returns the passed datafile from get', () => { expect(manager.get()).toEqual({ foo: 'abcd' }) }) - it('after being started, fetches the datafile and resolves onReady', async () => { + it('resolves onReady immediately', async () => { + manager.start() + await manager.onReady() + expect(manager.get()).toEqual({ foo: 'abcd' }) + }) + + it('after being started, fetches the datafile, updates itself, emits an update event, and updates itself again after a timeout', async () => { + manager.queuedResponses.push( + { + statusCode: 200, + body: '{"fooz": "barz"}', + headers: {} + }, + { + statusCode: 200, + body: '{"foo": "bar"}', + headers: {} + } + ) + const updateFn = jest.fn() + manager.on('update', updateFn) + manager.start() + expect(manager.responsePromises.length).toBe(1) + await manager.responsePromises[0] + expect(updateFn).toBeCalledTimes(1) + expect(updateFn).toBeCalledWith({ + datafile: { foo: 'bar' } + }) + expect(manager.get()).toEqual({ foo: 'bar' }) + updateFn.mockReset() + testTimeoutFactory.timeoutFns[0]() + expect(manager.responsePromises.length).toBe(2) + await manager.responsePromises[1] + expect(updateFn).toBeCalledTimes(1) + expect(updateFn).toBeCalledWith({ + datafile: { fooz: 'barz' } + }) + expect(manager.get()).toEqual({ fooz: 'barz' }) + }) + }) + + describe('when constructed with sdkKey and datafile and autoUpdate: false,', () => { + beforeEach(() => { + manager = createTestManager({ datafile: { foo: 'abcd' }, sdkKey: '123', autoUpdate: false }) + }) + + it('returns the passed datafile from get', () => { + expect(manager.get()).toEqual({ foo: 'abcd' }) + }) + + it('after being started, resolves onReady immediately', async () => { + manager.start() + await manager.onReady() + expect(manager.get()).toEqual({ foo: 'abcd' }) + }) + + it('after being started, fetches the datafile, updates itself once, and emits an update event, but does not schedule a future update', async () => { manager.queuedResponses.push({ statusCode: 200, body: '{"foo": "bar"}', headers: {} }) + const updateFn = jest.fn() + manager.on('update', updateFn) manager.start() - await manager.onReady() + expect(manager.responsePromises.length).toBe(1) + await manager.responsePromises[0] + expect(updateFn).toBeCalledTimes(1) + expect(updateFn).toBeCalledWith({ + datafile: { foo: 'bar' } + }) expect(manager.get()).toEqual({ foo: 'bar' }) + expect(testTimeoutFactory.timeoutFns.length).toBe(0) }) }) diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index 1708f46bd..eb3a40b04 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -92,12 +92,6 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.sdkKey = sdkKey - if (typeof datafile !== 'undefined') { - this.currentDatafile = datafile - } else { - this.currentDatafile = null - } - this.isReadyPromiseSettled = false this.readyPromiseResolver = () => {} this.readyPromiseRejecter = () => {} @@ -106,6 +100,13 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.readyPromiseRejecter = reject }) + if (typeof datafile !== 'undefined') { + this.currentDatafile = datafile + this.resolveReadyPromise() + } else { + this.currentDatafile = null + } + this.isStarted = false this.urlTemplate = urlTemplate @@ -204,7 +205,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.currentDatafile = datafile if (!this.isReadyPromiseSettled) { this.resolveReadyPromise() - } else if (this.autoUpdate) { + } else { const datafileUpdate: DatafileUpdate = { datafile, } From 70c342ab2fe5720aa0c560d22012a9b47a4026b3 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 2 Apr 2019 17:38:43 -0700 Subject: [PATCH 14/42] feat (datafile management): Initialize datafile manager in Optimizely constructor, add onReady method (#13) Summary: - Import and use datafile manager package. Create datafile manager instance in Optimizely constructor if `sdkKey`. is provided. - Provide `onReady` method, returning a promise based on the datafile manager's `onReady` method, or a fulfilled promise if no `sdkKey` was provided. - Update `isValidInstance` to distinguish between config validity and datafile/project config validity. - Move some validation/creation logic into a new function in the project config module: `tryCreatingProjectConfig` - Return early in top-level methods when `configObj` is not available Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4384 --- .../lib/core/project_config/index.js | 34 ++ .../lib/core/project_config/index.tests.js | 117 +++++++ .../project_config}/project_config_schema.js | 0 .../optimizely-sdk/lib/optimizely/index.js | 297 +++++++++++++--- .../lib/optimizely/index.tests.js | 328 +++++++++++++++++- .../optimizely-sdk/lib/utils/enums/index.js | 6 +- .../json_schema_validator/index.tests.js | 2 +- packages/optimizely-sdk/package-lock.json | 8 + packages/optimizely-sdk/package.json | 1 + 9 files changed, 722 insertions(+), 71 deletions(-) rename packages/optimizely-sdk/lib/{optimizely => core/project_config}/project_config_schema.js (100%) diff --git a/packages/optimizely-sdk/lib/core/project_config/index.js b/packages/optimizely-sdk/lib/core/project_config/index.js index 6f7129cf6..1568fb84a 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.js @@ -17,6 +17,8 @@ var fns = require('../../utils/fns'); var enums = require('../../utils/enums'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; var stringValidator = require('../../utils/string_value_validator'); +var configValidator = require('../../utils/config_validator'); +var projectConfigSchema = require('./project_config_schema'); var EXPERIMENT_LAUNCHED_STATUS = 'Launched'; var EXPERIMENT_RUNNING_STATUS = 'Running'; @@ -594,4 +596,36 @@ module.exports = { eventWithKeyExists: function(projectConfig, eventKey) { return projectConfig.eventKeyMap.hasOwnProperty(eventKey); }, + + /** + * Try to create a project config object from the given datafile and + * configuration properties. + * If successful, return the project config object, otherwise return null. + * @param {Object} config + * @param {Object} config.datafile + * @param {Object} config.errorHandler + * @param {Object} config.jsonSchemaValidator + * @param {Object} config.logger + * @param {Object} config.skipJSONValidation + * @return {Object|null} Project config object if datafile was valid, otherwise null + */ + tryCreatingProjectConfig: function(config) { + var configObj = null; + try { + configValidator.validateDatafile(config.datafile); + if (config.skipJSONValidation === true) { + configObj = module.exports.createProjectConfig(config.datafile); + config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME)); + } else if (!config.jsonSchemaValidator) { + configObj = module.exports.createProjectConfig(config.datafile); + } else if (config.jsonSchemaValidator.validate(projectConfigSchema, config.datafile)) { + configObj = module.exports.createProjectConfig(config.datafile); + config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME)); + } + } catch (ex) { + config.logger.log(LOG_LEVEL.ERROR, ex.message); + config.errorHandler.handleError(ex); + } + return configObj; + } }; diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index d6599ab4a..90caf8594 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -16,6 +16,7 @@ var projectConfig = require('./'); var enums = require('../../utils/enums'); var testDatafile = require('../../tests/test_data'); +var configValidator = require('../../utils/config_validator'); var _ = require('lodash/core'); var fns = require('../../utils/fns'); @@ -727,4 +728,120 @@ describe('lib/core/project_config', function() { assert.strictEqual(didSetVariation, false); }); }); + + describe('#tryCreatingProjectConfig', function() { + var stubErrorHandler; + var stubJsonSchemaValidator; + var stubLogger; + beforeEach(function() { + stubErrorHandler = { + handleError: sinon.stub(), + }; + stubJsonSchemaValidator = { + validate: sinon.stub().returns(true), + }; + stubLogger = { + log: sinon.stub(), + }; + sinon.stub(projectConfig, 'createProjectConfig').returns({}); + sinon.stub(configValidator, 'validateDatafile').returns(true); + }); + + afterEach(function() { + projectConfig.createProjectConfig.restore(); + configValidator.validateDatafile.restore(); + }); + + it('returns a project config object created by createProjectConfig when all validation is applied and there are no errors', function() { + configValidator.validateDatafile.returns(true); + stubJsonSchemaValidator.validate.returns(true); + var configObj = { + foo: 'bar', + experimentKeyMap: { + a: { key: 'a' }, + b: { key: 'b' }, + } + }; + projectConfig.createProjectConfig.returns(configObj); + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: stubLogger, + skipJSONValidation: false, + }); + assert.deepEqual(result, configObj); + }); + + it('returns null and calls handleError when validateDatafile throws', function() { + configValidator.validateDatafile.throws(); + stubJsonSchemaValidator.validate.returns(true); + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: stubLogger, + skipJSONValidation: false, + }); + assert.strictEqual(result, null); + sinon.assert.calledOnce(stubErrorHandler.handleError); + }); + + it('returns null and calls handleError when jsonSchemaValidator.validate throws', function() { + configValidator.validateDatafile.returns(true); + stubJsonSchemaValidator.validate.throws(); + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: stubLogger, + skipJSONValidation: false, + }); + assert.strictEqual(result, null); + sinon.assert.calledOnce(stubErrorHandler.handleError); + }); + + it('returns null when jsonSchemaValidator.validate returns false', function() { + configValidator.validateDatafile.returns(true); + stubJsonSchemaValidator.validate.returns(false); + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: stubLogger, + skipJSONValidation: false, + }); + assert.strictEqual(result, null); + }); + + it('does not call jsonSchemaValidator.validate when skipJSONValidation is true', function() { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: stubLogger, + skipJSONValidation: true, + }); + sinon.assert.notCalled(stubJsonSchemaValidator.validate); + }); + + it('skips json validation when jsonSchemaValidator is not provided', function() { + configValidator.validateDatafile.returns(true); + var configObj = { + foo: 'bar', + experimentKeyMap: { + a: { key: 'a' }, + b: { key: 'b' }, + } + }; + projectConfig.createProjectConfig.returns(configObj); + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + errorHandler: stubErrorHandler, + logger: stubLogger, + }); + assert.deepEqual(result, configObj); + sinon.assert.notCalled(stubErrorHandler.handleError); + }); + }); }); diff --git a/packages/optimizely-sdk/lib/optimizely/project_config_schema.js b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js similarity index 100% rename from packages/optimizely-sdk/lib/optimizely/project_config_schema.js rename to packages/optimizely-sdk/lib/core/project_config/project_config_schema.js diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index 9ff4105e6..3c45bf8d1 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -16,6 +16,7 @@ var fns = require('../utils/fns'); var attributesValidator = require('../utils/attributes_validator'); +var datafileManager = require('@optimizely/js-sdk-datafile-manager'); var decisionService = require('../core/decision_service'); var enums = require('../utils/enums'); var eventBuilder = require('../core/event_builder/index.js'); @@ -25,7 +26,6 @@ var eventProcessor = require('@optimizely/js-sdk-event-processor'); var eventTagsValidator = require('../utils/event_tags_validator'); var notificationCenter = require('../core/notification_center'); var projectConfig = require('../core/project_config'); -var projectConfigSchema = require('./project_config_schema'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; var userProfileServiceValidator = require('../utils/user_profile_service_validator'); var stringValidator = require('../utils/string_value_validator'); @@ -68,29 +68,10 @@ function Optimizely(config) { this.clientVersion = config.clientVersion || enums.NODE_CLIENT_VERSION; this.errorHandler = config.errorHandler; this.eventDispatcher = new EventDispatcherBridge(config.eventDispatcher); - this.isValidInstance = config.isValidInstance; + this.__isOptimizelyConfigValid = config.isValidInstance; this.logger = config.logger; - try { - configValidator.validateDatafile(config.datafile); - if (typeof config.datafile === 'string' || config.datafile instanceof String) { - config.datafile = JSON.parse(config.datafile); - } - - if (config.skipJSONValidation === true) { - this.configObj = projectConfig.createProjectConfig(config.datafile); - this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME)); - } else { - if (config.jsonSchemaValidator.validate(projectConfigSchema, config.datafile)) { - this.configObj = projectConfig.createProjectConfig(config.datafile); - this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME)); - } - } - } catch (ex) { - this.isValidInstance = false; - this.logger.log(LOG_LEVEL.ERROR, ex.message); - this.errorHandler.handleError(ex); - } + this.__initializeProjectConfigProperties(config); var userProfileService = null; if (config.userProfileService) { @@ -122,6 +103,120 @@ function Optimizely(config) { this.eventProcessor.start(); } +/** + * Initialize several properties, based on the argument config: + * - this.configObj: The project config object + * - this.datafileManager: A datafile manager that provides a ready promise + * and a stream of datafile update events + * - this.__readyPromise: Promise that will be fulfilled when this instance + * has a valid project config object for the first time + * The datafile and sdkKey provided in the argument config are used to + * initialize configObj, datafileManager, and __readyPromise. + * @param {Object} config + * @param {Object} config.datafile + * @param {Object} config.sdkKey + * @param {Object} config.jsonSchemaValidator + * @param {Object} config.skipJSONValidation + */ +Optimizely.prototype.__initializeProjectConfigProperties = function(config) { + try { + if (!config.datafile && !config.sdkKey) { + this.configObj = null; + this.__readyPromise = Promise.resolve(); + var datafileAndSdkKeyMissingError = new Error(sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME)); + this.logger.log(LOG_LEVEL.ERROR, datafileAndSdkKeyMissingError.message); + this.errorHandler.handleError(datafileAndSdkKeyMissingError); + return; + } + + var initialDatafile = this.__getDatafileFromConfig(config); + + if (config.sdkKey) { + var datafileManagerConfig = { + sdkKey: config.sdkKey, + }; + if (initialDatafile) { + datafileManagerConfig.datafile = initialDatafile; + } + this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig); + this.datafileManager.start(); + initialDatafile = this.datafileManager.get(); + } + + if (initialDatafile) { + this.configObj = projectConfig.tryCreatingProjectConfig({ + datafile: initialDatafile, + errorHandler: this.errorHandler, + jsonSchemaValidator: config.jsonSchemaValidator, + logger: this.logger, + skipJSONValidation: config.skipJSONValidation, + }); + } else { + this.configObj = null; + } + + if (this.datafileManager) { + this.__readyPromise = this.datafileManager.onReady().then(function() { + var newDatafile = this.datafileManager.get(); + var newConfigObj = projectConfig.tryCreatingProjectConfig({ + datafile: newDatafile, + errorHandler: this.errorHandler, + jsonSchemaValidator: config.jsonSchemaValidator, + logger: this.logger, + skipJSONValidation: config.skipJSONValidation, + }); + if (newConfigObj) { + this.configObj = newConfigObj; + this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.UPDATED_PROJECT_CONFIG, MODULE_NAME, this.configObj.revision)); + } + }.bind(this)); + } else { + this.__readyPromise = Promise.resolve(); + } + } catch (ex) { + this.configObj = null; + this.__readyPromise = Promise.resolve(); + this.logger.log(LOG_LEVEL.ERROR, ex.message); + this.errorHandler.handleError(ex); + } +}; + +/** + * If the argument config contains a valid datafile object or string, + * return a datafile object based on that provided datafile, otherwise + * return null. + * @param {Object} config + * @param {Object} config.datafile + * @return {Object|null} + */ +Optimizely.prototype.__getDatafileFromConfig = function(config) { + var initialDatafile = null; + try { + if (config.datafile) { + configValidator.validateDatafile(config.datafile); + if (typeof config.datafile === 'string' || config.datafile instanceof String) { + initialDatafile = JSON.parse(config.datafile); + } else { + initialDatafile = config.datafile; + } + } + } catch (ex) { + this.logger.log(LOG_LEVEL.ERROR, ex.message); + this.errorHandler.handleError(ex); + } + return initialDatafile; +}; + +/** + * Returns a truthy value if this instance currently has a valid project config + * object, and the initial configuration object that was passed into the + * constructor was also valid. + * @return {*} + */ +Optimizely.prototype.__isValidInstance = function() { + return this.__isOptimizelyConfigValid && this.configObj; +}; + /** * Buckets visitor and sends impression event to Optimizely. * @param {string} experimentKey @@ -131,7 +226,7 @@ function Optimizely(config) { */ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { try { - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'activate')); return null; } @@ -140,6 +235,11 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { return this.__notActivatingExperiment(experimentKey, userId); } + var configObj = this.configObj; + if (!configObj) { + return null; + } + try { var variationKey = this.getVariation(experimentKey, userId, attributes); if (variationKey === null) { @@ -147,7 +247,7 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { } // If experiment is not set to 'Running' status, log accordingly and return variation key - if (!projectConfig.isRunning(this.configObj, experimentKey)) { + if (!projectConfig.isRunning(configObj, experimentKey)) { var shouldNotDispatchActivateLogMessage = sprintf( LOG_MESSAGES.SHOULD_NOT_DISPATCH_ACTIVATE, MODULE_NAME, @@ -184,6 +284,11 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { * @param {Object} attributes Optional user attributes */ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey, userId, attributes) { + var configObj = this.configObj; + if (!configObj) { + return; + } + var impressionEvent = eventHelpers.buildImpressionEvent({ experimentKey: experimentKey, variationKey: variationKey, @@ -191,7 +296,7 @@ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey userAttributes: attributes, clientEngine: this.clientEngine, clientVersion: this.clientVersion, - configObj: this.configObj, + configObj: configObj, }); // TODO is it okay to not pass a projectConfig as second argument this.eventProcessor.process(impressionEvent); @@ -206,24 +311,29 @@ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey * @param {Object} attributes Optional user attributes */ Optimizely.prototype.__emitNotificationCenterActivate = function(experimentKey, variationKey, userId, attributes) { + var configObj = this.configObj; + if (!configObj) { + return; + } + var variationId = projectConfig.getVariationIdFromExperimentAndVariationKey( - this.configObj, + configObj, experimentKey, variationKey ); - var experimentId = projectConfig.getExperimentId(this.configObj, experimentKey); + var experimentId = projectConfig.getExperimentId(configObj, experimentKey); var impressionEventOptions = { attributes: attributes, clientEngine: this.clientEngine, clientVersion: this.clientVersion, - configObj: this.configObj, + configObj: configObj, experimentId: experimentId, userId: userId, variationId: variationId, logger: this.logger, }; var impressionEvent = eventBuilder.getImpressionEvent(impressionEventOptions); - var experiment = this.configObj.experimentKeyMap[experimentKey]; + var experiment = configObj.experimentKeyMap[experimentKey]; var variation; if (experiment && experiment.variationKeyMap) { variation = experiment.variationKeyMap[variationKey]; @@ -249,7 +359,7 @@ Optimizely.prototype.__emitNotificationCenterActivate = function(experimentKey, */ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { try { - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'track')); return; } @@ -258,7 +368,12 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { return; } - if (!projectConfig.eventWithKeyExists(this.configObj, eventKey)) { + var configObj = this.configObj; + if (!configObj) { + return; + } + + if (!projectConfig.eventWithKeyExists(configObj, eventKey)) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EVENT_KEY, MODULE_NAME, eventKey)); } @@ -271,7 +386,7 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { userAttributes: attributes, clientEngine: this.clientEngine, clientVersion: this.clientVersion, - configObj: this.configObj, + configObj: configObj, }); // TODO is it okay to not pass a projectConfig as second argument this.eventProcessor.process(conversionEvent); @@ -293,11 +408,16 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { */ Optimizely.prototype.__emitNotificationCenterTrack = function(eventKey, userId, attributes, eventTags) { try { + var configObj = this.configObj; + if (!configObj) { + return; + } + var conversionEventOptions = { attributes: attributes, clientEngine: this.clientEngine, clientVersion: this.clientVersion, - configObj: this.configObj, + configObj: configObj, eventKey: eventKey, eventTags: eventTags, logger: this.logger, @@ -327,7 +447,7 @@ Optimizely.prototype.__emitNotificationCenterTrack = function(eventKey, userId, */ Optimizely.prototype.getVariation = function(experimentKey, userId, attributes) { try { - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getVariation')); return null; } @@ -337,13 +457,18 @@ Optimizely.prototype.getVariation = function(experimentKey, userId, attributes) return null; } - var experiment = this.configObj.experimentKeyMap[experimentKey]; + var configObj = this.configObj; + if (!configObj) { + return null; + } + + var experiment = configObj.experimentKeyMap[experimentKey]; if (fns.isEmpty(experiment)) { this.logger.log(LOG_LEVEL.DEBUG, sprintf(ERROR_MESSAGES.INVALID_EXPERIMENT_KEY, MODULE_NAME, experimentKey)); return null; } - var variationKey = this.decisionService.getVariation(this.configObj, experimentKey, userId, attributes); + var variationKey = this.decisionService.getVariation(configObj, experimentKey, userId, attributes); this.notificationCenter.sendNotifications( NOTIFICATION_TYPES.DECISION, { @@ -382,8 +507,13 @@ Optimizely.prototype.setForcedVariation = function(experimentKey, userId, variat return false; } + var configObj = this.configObj; + if (!configObj) { + return null; + } + try { - return projectConfig.setForcedVariation(this.configObj, experimentKey, userId, variationKey, this.logger); + return projectConfig.setForcedVariation(configObj, experimentKey, userId, variationKey, this.logger); } catch (ex) { this.logger.log(LOG_LEVEL.ERROR, ex.message); this.errorHandler.handleError(ex); @@ -402,8 +532,13 @@ Optimizely.prototype.getForcedVariation = function(experimentKey, userId) { return null; } + var configObj = this.configObj; + if (!configObj) { + return null; + } + try { - return projectConfig.getForcedVariation(this.configObj, experimentKey, userId, this.logger); + return projectConfig.getForcedVariation(configObj, experimentKey, userId, this.logger); } catch (ex) { this.logger.log(LOG_LEVEL.ERROR, ex.message); this.errorHandler.handleError(ex); @@ -487,7 +622,7 @@ Optimizely.prototype.__filterEmptyValues = function(map) { */ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) { try { - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'isFeatureEnabled')); return false; } @@ -496,7 +631,12 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) return false; } - var feature = projectConfig.getFeatureFromKey(this.configObj, featureKey, this.logger); + var configObj = this.configObj; + if (!configObj) { + return false; + } + + var feature = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger); if (!feature) { return false; } @@ -504,9 +644,9 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) var featureEnabled = false; var experimentKey = null; var variationKey = null; - var decision = this.decisionService.getVariationForFeature(this.configObj, feature, userId, attributes); + var decision = this.decisionService.getVariationForFeature(configObj, feature, userId, attributes); var variation = decision.variation; - + if (!!variation) { featureEnabled = variation.featureEnabled; if (decision.decisionSource === DECISION_SOURCES.EXPERIMENT) { @@ -516,7 +656,7 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) this._sendImpressionEvent(decision.experiment.key, decision.variation.key, userId, attributes); } } - + if (featureEnabled === true) { this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.FEATURE_ENABLED_FOR_USER, MODULE_NAME, featureKey, userId)); } else { @@ -558,7 +698,7 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) Optimizely.prototype.getEnabledFeatures = function(userId, attributes) { try { var enabledFeatures = []; - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, 'getEnabledFeatures')); return enabledFeatures; } @@ -567,8 +707,13 @@ Optimizely.prototype.getEnabledFeatures = function(userId, attributes) { return enabledFeatures; } + var configObj = this.configObj; + if (!configObj) { + return enabledFeatures; + } + fns.forOwn( - this.configObj.featureKeyMap, + configObj.featureKeyMap, function(feature) { if (this.isFeatureEnabled(feature.key, userId, attributes)) { enabledFeatures.push(feature.key); @@ -605,7 +750,7 @@ Optimizely.prototype.getEnabledFeatures = function(userId, attributes) { * with the type of the variable */ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableKey, variableType, userId, attributes) { - if (!this.isValidInstance) { + if (!this.__isValidInstance()) { var apiName = 'getFeatureVariable' + variableType.charAt(0).toUpperCase() + variableType.slice(1); this.logger.log(LOG_LEVEL.ERROR, sprintf(LOG_MESSAGES.INVALID_OBJECT, MODULE_NAME, apiName)); return null; @@ -615,12 +760,17 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK return null; } - var featureFlag = projectConfig.getFeatureFromKey(this.configObj, featureKey, this.logger); + var configObj = this.configObj; + if (!configObj) { + return null; + } + + var featureFlag = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger); if (!featureFlag) { return null; } - var variable = projectConfig.getVariableForFeature(this.configObj, featureKey, variableKey, this.logger); + var variable = projectConfig.getVariableForFeature(configObj, featureKey, variableKey, this.logger); if (!variable) { return null; } @@ -633,11 +783,11 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK return null; } - var decision = this.decisionService.getVariationForFeature(this.configObj, featureFlag, userId, attributes); + var decision = this.decisionService.getVariationForFeature(configObj, featureFlag, userId, attributes); var variableValue; if (decision.variation !== null) { variableValue = projectConfig.getVariableValueForVariation( - this.configObj, + configObj, variable, decision.variation, this.logger @@ -772,4 +922,51 @@ Optimizely.prototype.close = function() { } }; +/** + * Returns a Promise representing the readiness of this instance. + * + * Currently, readiness is based on the initial datafile fetch process, as well + * as the argument timeoutMs (if timeoutMs is provided). + * + * If no sdkKey was provided when the instance was created, then no datafile + * fetch will take place. This method will return an immediately fulfilled + * Promise. + * + * If timeoutMs is not provided, returns a Promise that fulfills immediately + * after the initial datafile fetch, parse, and project config object creation + * has completed. At this point, if the datafile provided by the datafile + * manager is valid and was successfully parsed into a project config object, + * this instance is ready to be used, and all entities from the datafile + * manager's datafile will be present in its project config object. + * + * If the initial datafile fetch failed, this promise will be rejected. + * + * If the initial datafile fetch succeeded, but the datafile was invalid or + * project config creation failed, this promise will be fulfilled, but this + * instance will continue to use whatever previous project config it had. If no + * previous project config was available, this instance will remain invalid. + * + * TODO: Mention autoUpdate after we implement it + * + * If a number timeoutMs argument is provided, the returned Promise will + * fullfill or reject as soon as one of the following happens: + * 1) Datafile fetch succeeds as described above (fulfills) + * 2) Datafile fetch fails as described above (rejects) + * 3) timeoutMs milliseconds have elapsed (fulfills) + * + * @param {number|undefined} timeoutMs + * @return {Promise} + */ +Optimizely.prototype.onReady = function(timeoutMs) { + if (!fns.isFinite(timeoutMs)) { + return this.__readyPromise; + } + var timeoutPromise = new Promise(function(resolve) { + setTimeout(function() { + resolve(); + }, timeoutMs); + }); + return Promise.race([this.__readyPromise, timeoutPromise]); +}; + module.exports = Optimizely; diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 1e466acf1..d0fe70013 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -18,6 +18,7 @@ var Optimizely = require('./'); var audienceEvaluator = require('../core/audience_evaluator'); var bluebird = require('bluebird'); var bucketer = require('../core/bucketer'); +var datafileManager = require('@optimizely/js-sdk-datafile-manager'); var enums = require('../utils/enums'); var eventBuilder = require('../core/event_builder/index.js'); var eventDispatcher = require('../plugins/event_dispatcher/index.node'); @@ -43,6 +44,23 @@ var DECISION_SOURCES = enums.DECISION_SOURCES; var DECISION_INFO_TYPES = enums.DECISION_INFO_TYPES; describe('lib/optimizely', function() { + var DatafileManagerStub; + beforeEach(function() { + DatafileManagerStub = sinon.stub(datafileManager, 'DatafileManager').callsFake(function() { + return { + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(null), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns({ then: function() {} }) + }; + }); + }); + + afterEach(function() { + DatafileManagerStub.restore(); + }); + describe('constructor', function() { var stubErrorHandler = { handleError: function() {}}; var stubEventDispatcher = { dispatchEvent: function() { return bluebird.resolve(null); } }; @@ -70,7 +88,7 @@ describe('lib/optimizely', function() { assert.instanceOf(optlyInstance, Optimizely); sinon.assert.called(createdLogger.log); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'OPTIMIZELY')); + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); }); it('should construct an instance of the Optimizely class when datafile is JSON string', function() { @@ -85,7 +103,7 @@ describe('lib/optimizely', function() { assert.instanceOf(optlyInstance, Optimizely); sinon.assert.called(createdLogger.log); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'OPTIMIZELY')); + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); }); it('should log if the client engine passed in is invalid', function() { @@ -101,7 +119,7 @@ describe('lib/optimizely', function() { assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.INVALID_CLIENT_ENGINE, 'OPTIMIZELY', 'undefined')); }); - it('should throw an error if a datafile is not passed into the constructor', function() { + it('should throw an error if neither datafile nor sdkKey are passed into the constructor', function() { var optly = new Optimizely({ clientEngine: 'node-sdk', errorHandler: stubErrorHandler, @@ -109,13 +127,13 @@ describe('lib/optimizely', function() { }); sinon.assert.calledOnce(stubErrorHandler.handleError); var errorMessage = stubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.NO_DATAFILE_SPECIFIED, 'CONFIG_VALIDATOR')); + assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'OPTIMIZELY')); sinon.assert.calledOnce(createdLogger.log); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.NO_DATAFILE_SPECIFIED, 'CONFIG_VALIDATOR')); + assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'OPTIMIZELY')); - assert.isFalse(optly.isValidInstance); + assert.isFalse(!!optly.__isValidInstance()); }); it('should throw an error if the datafile JSON is malformed', function() { @@ -208,7 +226,7 @@ describe('lib/optimizely', function() { sinon.assert.calledOnce(jsonSchemaValidator.validate); sinon.assert.calledOnce(createdLogger.log); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'OPTIMIZELY')); + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); }); }); @@ -268,6 +286,123 @@ describe('lib/optimizely', function() { assert.strictEqual(logMessage, 'USER_PROFILE_SERVICE_VALIDATOR: Provided user profile service instance is in an invalid format: Missing function \'lookup\'.'); }); }); + + describe('when an sdkKey is provided', function() { + it('should not log an error when sdkKey is provided and datafile is not provided', function() { + new Optimizely({ + clientEngine: 'node-sdk', + eventBuilder: eventBuilder, + errorHandler: stubErrorHandler, + eventDispatcher: eventDispatcher, + isValidInstance: true, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + sinon.assert.notCalled(stubErrorHandler.handleError); + }); + + it('creates and starts a datafile manager', function() { + new Optimizely({ + clientEngine: 'node-sdk', + eventBuilder: eventBuilder, + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + isValidInstance: true, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + sinon.assert.calledOnce(datafileManager.DatafileManager); + sinon.assert.calledOnce(datafileManager.DatafileManager.getCall(0).returnValue.start); + }); + + it('passes the sdkKey and the datafile to the datafile manager', function() { + new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfig(), + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + isValidInstance: true, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + sinon.assert.calledOnce(datafileManager.DatafileManager); + sinon.assert.calledWithExactly(datafileManager.DatafileManager, { + datafile: testData.getTestProjectConfig(), + sdkKey: '12345', + }); + }); + + it('passes only sdkKey to the datafile manager when no datafile was provided', function() { + new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + isValidInstance: true, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + sinon.assert.calledOnce(datafileManager.DatafileManager); + sinon.assert.calledWithExactly(datafileManager.DatafileManager, { + sdkKey: '12345', + }); + }); + + it('uses the datafile returned from the datafile manager get() for the immediately-available project config object', function() { + var differentDatafile = testData.getTestProjectConfig(); + differentDatafile.experiments.push({ + key: 'myOtherExperiment', + status: 'Running', + forcedVariations: { + }, + audienceIds: [], + layerId: '5', + trafficAllocation: [ + { + entityId: '99999999', + endOfRange: 10000, + }, + ], + id: '999998888777776', + variations: [ + { + key: 'control', + id: '99999999', + }, + ], + }); + differentDatafile.revision = '44'; + datafileManager.DatafileManager.callsFake(function() { + return { + start: sinon.stub(), + stop: sinon.stub(), + // Return different datafile from get method -- this should be used immediately by Optimizely + get: sinon.stub().returns(differentDatafile), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns({ then: function() {} }) + }; + }); + var optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + datafile: testData.getTestProjectConfig(), + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + isValidInstance: true, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + assert.strictEqual(optlyInstance.activate('myOtherExperiment', 'user12345'), 'control'); + }); + }); }); }); @@ -2438,7 +2573,7 @@ describe('lib/optimizely', function() { }); }); }); - + describe('feature management', function() { var sandbox = sinon.sandbox.create(); @@ -2459,15 +2594,15 @@ describe('lib/optimizely', function() { decisionListener ); }); - + afterEach(function() { sandbox.restore(); }); - + describe('isFeatureEnabled', function() { describe('when the user bucketed into a variation of an experiment of the feature', function() { var attributes = { test_attribute: 'test_value' }; - + describe('when the variation is toggled ON', function() { beforeEach(function() { var experiment = optlyInstance.configObj.experimentKeyMap.testing_my_feature; @@ -2478,7 +2613,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.EXPERIMENT, }); }); - + it('should return true and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1', attributes); assert.strictEqual(result, true); @@ -2539,7 +2674,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('should return true and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { test_attribute: 'test_value', @@ -2559,7 +2694,7 @@ describe('lib/optimizely', function() { }); }); }); - + describe('when the variation is toggled OFF', function() { beforeEach(function() { // This experiment is the second audience targeting rule in the rollout of feature 'test_feature' @@ -2571,14 +2706,14 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('returns false and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { test_attribute: 'test_value', }); assert.strictEqual(result, false); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Feature test_feature is not enabled for user user1.'); - + var expectedArguments = { type: DECISION_INFO_TYPES.FEATURE, userId: 'user1', @@ -2604,7 +2739,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('returns false and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); assert.strictEqual(result, false); @@ -3181,7 +3316,7 @@ describe('lib/optimizely', function() { logger: createdLogger, isValidInstance: true, }); - + var decisionListener = sinon.spy(); var attributes = { test_attribute: 'test_value' }; optlyInstance.notificationCenter.addNotificationListener(enums.NOTIFICATION_TYPES.DECISION, decisionListener); @@ -4127,4 +4262,161 @@ describe('lib/optimizely', function() { }); }); }); + + describe('datafile management', function() { + var createdLogger = logger.createLogger({ + logLevel: LOG_LEVEL.INFO, + logToConsole: false, + }); + + beforeEach(function() { + sinon.stub(eventDispatcher, 'dispatchEvent'); + sinon.stub(errorHandler, 'handleError'); + sinon.stub(createdLogger, 'log'); + }); + + afterEach(function() { + eventDispatcher.dispatchEvent.restore(); + errorHandler.handleError.restore(); + createdLogger.log.restore(); + }); + + var optlyInstance; + + describe('when no datafile is available yet ', function() { + beforeEach(function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + }); + + it('returns fallback values from API methods that return meaningful values', function() { + assert.isNull(optlyInstance.activate('my_experiment', 'user1')); + assert.isNull(optlyInstance.getVariation('my_experiment', 'user1')); + assert.isNull(optlyInstance.setForcedVariation('my_experiment', 'user1', 'variation_1')); + assert.isNull(optlyInstance.getForcedVariation('my_experiment', 'user1')); + assert.isFalse(optlyInstance.isFeatureEnabled('my_feature', 'user1')); + assert.deepEqual(optlyInstance.getEnabledFeatures('user1'), []); + assert.isNull(optlyInstance.getFeatureVariableBoolean('my_feature', 'my_bool_var', 'user1')); + assert.isNull(optlyInstance.getFeatureVariableDouble('my_feature', 'my_double_var', 'user1')); + assert.isNull(optlyInstance.getFeatureVariableInteger('my_feature', 'my_int_var', 'user1')); + assert.isNull(optlyInstance.getFeatureVariableString('my_feature', 'my_str_var', 'user1')); + }); + + it('does not dispatch any events in API methods that dispatch events', function() { + optlyInstance.activate('my_experiment', 'user1'); + optlyInstance.track('my_event', 'user1'); + optlyInstance.isFeatureEnabled('my_feature', 'user1'); + optlyInstance.getEnabledFeatures('user1'); + sinon.assert.notCalled(eventDispatcher.dispatchEvent); + }); + }); + + describe('onReady method', function() { + var datafileManagerOnReady; + var fulfillDatafileManagerOnReady; + beforeEach(function() { + var fakeDatafileManager = { + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(null), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub() + }; + datafileManagerOnReady = new Promise(function(fulfill) { + fulfillDatafileManagerOnReady = function(updatedConfig) { + fakeDatafileManager.get.returns(updatedConfig); + fulfill(); + }; + }); + fakeDatafileManager.onReady.returns(datafileManagerOnReady); + datafileManager.DatafileManager.returns(fakeDatafileManager); + + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + }); + + afterEach(function() { + fulfillDatafileManagerOnReady(null); + return datafileManagerOnReady; + }); + + describe('feature management methods', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + beforeEach(function() { + var experiment = configWithFeatures.experiments[0]; + var variation = experiment.variations[0]; + sinon.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ + experiment: experiment, + variation: variation, + decisionSource: DECISION_SOURCES.EXPERIMENT, + }); + }); + + it('updates the datafile it uses from the datafile manager get method after the datafile manager onReady promise fulfills', function() { + fulfillDatafileManagerOnReady(configWithFeatures); + return optlyInstance.onReady().then(function() { + assert.isTrue(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1')); + assert.strictEqual( + optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1'), + true + ); + eventDispatcher.dispatchEvent.reset(); + optlyInstance.track('item_bought', 'user'); + sinon.assert.calledOnce(eventDispatcher.dispatchEvent); + }); + }); + }); + + describe('experimentation methods', function() { + var configObj = testData.getTestProjectConfig(); + beforeEach(function() { + sinon.stub(optlyInstance.decisionService, 'getVariation').returns('control'); + }); + + it('updates the datafile it uses from the datafile manager get method after the datafile manager onReady promise fulfills', function() { + fulfillDatafileManagerOnReady(configObj); + return optlyInstance.onReady().then(function() { + assert.strictEqual( + optlyInstance.activate('testExperiment', 'user_123'), + 'control' + ); + eventDispatcher.dispatchEvent.reset(); + optlyInstance.track('testEvent', 'user_123'); + sinon.assert.calledOnce(eventDispatcher.dispatchEvent); + }); + }); + }); + + describe('timeout', function() { + var clock; + beforeEach(function() { + clock = sinon.useFakeTimers(new Date().getTime()); + }); + + afterEach(function() { + clock.restore(); + }); + + it('fulfills the promise after the timeout has expired when the datafile manager promise still has not fulfilled', function() { + var readyPromise = optlyInstance.onReady(500); + clock.tick(501); + return readyPromise; + }); + }); + }); + }); }); diff --git a/packages/optimizely-sdk/lib/utils/enums/index.js b/packages/optimizely-sdk/lib/utils/enums/index.js index 726df335a..feb3c08ce 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.js +++ b/packages/optimizely-sdk/lib/utils/enums/index.js @@ -26,6 +26,7 @@ exports.LOG_LEVEL = { }; exports.ERROR_MESSAGES = { + DATAFILE_AND_SDK_KEY_MISSING: '%s: You must provide at least one of sdkKey or datafile. Cannot start Optimizely', EXPERIMENT_KEY_NOT_IN_DATAFILE: '%s: Experiment key %s is not in datafile.', FEATURE_NOT_IN_DATAFILE: '%s: Feature key %s is not in datafile.', IMPROPERLY_FORMATTED_EXPERIMENT: '%s: Experiment key %s is improperly formatted.', @@ -133,6 +134,7 @@ exports.LOG_MESSAGES = { UNEXPECTED_TYPE_NULL: '%s: Audience condition %s evaluated to UNKNOWN because a null value was passed for user attribute "%s".', UNKNOWN_CONDITION_TYPE: '%s: Audience condition %s has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.', UNKNOWN_MATCH_TYPE: '%s: Audience condition %s uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.', + UPDATED_PROJECT_CONFIG: '%s: Updated project config to revision %s', OUT_OF_BOUNDS: '%s: Audience condition %s evaluated to UNKNOWN because the number value for user attribute "%s" is not in the range [-2^53, +2^53].', }; @@ -173,8 +175,8 @@ exports.NODE_CLIENT_VERSION = '3.1.0-beta1'; * - attributes {Object|undefined} * - eventTags {Object|undefined} * - logEvent {Object} - * - * DECISION: A decision is made in the system. i.e. user activation, + * + * DECISION: A decision is made in the system. i.e. user activation, * feature access or feature-variable value retrieval * Callbacks will receive an object argument with the following properties: * - type {string} diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js index 1224dfa0e..df1cbd88b 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js @@ -16,7 +16,7 @@ var chai = require('chai'); var assert = chai.assert; var jsonSchemaValidator = require('./'); -var projectConfigSchema = require('../../optimizely/project_config_schema'); +var projectConfigSchema = require('../../core/project_config/project_config_schema'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; var testData = require('../../tests/test_data.js'); diff --git a/packages/optimizely-sdk/package-lock.json b/packages/optimizely-sdk/package-lock.json index 816266fd6..774baf28b 100644 --- a/packages/optimizely-sdk/package-lock.json +++ b/packages/optimizely-sdk/package-lock.json @@ -4,6 +4,14 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@optimizely/js-sdk-datafile-manager": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.1.0.tgz", + "integrity": "sha512-y0nkFeUWUXOfoJVMgiaAFB+gyPl+v81PT621OdAa3Xd5sf/lOMcfalog3awcJGMlobVD5XwA5nDmdSZUGRLTJQ==", + "requires": { + "@optimizely/js-sdk-logging": "^0.1.0" + } + }, "@optimizely/js-sdk-event-processor": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.1.0.tgz", diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index 3ed30808b..641a9156a 100644 --- a/packages/optimizely-sdk/package.json +++ b/packages/optimizely-sdk/package.json @@ -32,6 +32,7 @@ }, "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/optimizely-sdk", "dependencies": { + "@optimizely/js-sdk-datafile-manager": "^0.1.0", "@optimizely/js-sdk-event-processor": "^0.1.0", "@optimizely/js-sdk-logging": "^0.1.0", "@optimizely/js-sdk-utils": "^0.1.0", From c987725dcd81153eaf2218c994bdae16b9b895c9 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 8 Apr 2019 11:15:17 -0700 Subject: [PATCH 15/42] feat (datafile management): Implement automatic updates, datafile manager options, and stopping datafile manager (#15) Summary: - Refactor project config management into a ProjectConfigManager class, which uses DatafileManager internally - Update Optimizely to get project config by calling projectConfigManager.getConfig() instead of keeping its own reference to a project config object - Pass through datafileOptions to project config manager (allowing use of autoUpdate and updateInterval) - Add notification center event for new project config - Add an update listener to datafile manager that sends project config update notification - Call this.datafileManager.stop in close method - Add default timeout to onReady method - Update behavior of onReady to reject early when datafile manager emits invalid datafile Test plan: Unit tests Issues: https://optimizely.atlassian.net/browse/OASIS-4385 https://optimizely.atlassian.net/browse/OASIS-4386 https://optimizely.atlassian.net/browse/OASIS-4387 --- .../lib/core/project_config/index.js | 30 +- .../lib/core/project_config/index.tests.js | 81 +-- .../project_config/project_config_manager.js | 255 ++++++++ .../project_config_manager.tests.js | 265 ++++++++ .../optimizely-sdk/lib/optimizely/index.js | 208 ++----- .../lib/optimizely/index.tests.js | 565 +++++++----------- .../optimizely-sdk/lib/utils/enums/index.js | 7 + 7 files changed, 833 insertions(+), 578 deletions(-) create mode 100644 packages/optimizely-sdk/lib/core/project_config/project_config_manager.js create mode 100644 packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js diff --git a/packages/optimizely-sdk/lib/core/project_config/index.js b/packages/optimizely-sdk/lib/core/project_config/index.js index a1300cafe..9afe58024 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.js @@ -600,32 +600,22 @@ module.exports = { /** * Try to create a project config object from the given datafile and * configuration properties. - * If successful, return the project config object, otherwise return null. + * If successful, return the project config object, otherwise throws an error * @param {Object} config * @param {Object} config.datafile - * @param {Object} config.errorHandler * @param {Object} config.jsonSchemaValidator * @param {Object} config.logger * @param {Object} config.skipJSONValidation - * @return {Object|null} Project config object if datafile was valid, otherwise null + * @return {Object} Project config object */ tryCreatingProjectConfig: function(config) { - var configObj = null; - try { - configValidator.validateDatafile(config.datafile); - if (config.skipJSONValidation === true) { - configObj = module.exports.createProjectConfig(config.datafile); - config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME)); - } else if (!config.jsonSchemaValidator) { - configObj = module.exports.createProjectConfig(config.datafile); - } else if (config.jsonSchemaValidator.validate(projectConfigSchema, config.datafile)) { - configObj = module.exports.createProjectConfig(config.datafile); - config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME)); - } - } catch (ex) { - config.logger.log(LOG_LEVEL.ERROR, ex.message); - config.errorHandler.handleError(ex); + configValidator.validateDatafile(config.datafile); + if (config.skipJSONValidation === true) { + config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.SKIPPING_JSON_VALIDATION, MODULE_NAME)); + } else if (config.jsonSchemaValidator) { + config.jsonSchemaValidator.validate(projectConfigSchema, config.datafile); + config.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.VALID_DATAFILE, MODULE_NAME)); } - return configObj; - } + return module.exports.createProjectConfig(config.datafile); + }, }; diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index d6c5d05cb..aadf25493 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -17,12 +17,15 @@ var projectConfig = require('./'); var enums = require('../../utils/enums'); var testDatafile = require('../../tests/test_data'); var configValidator = require('../../utils/config_validator'); +var logging = require('@optimizely/js-sdk-logging'); + +var logger = logging.getLogger(); var _ = require('lodash/core'); var fns = require('../../utils/fns'); var chai = require('chai'); var assert = chai.assert; -var logger = require('../../plugins/logger'); +var loggerPlugin = require('../../plugins/logger'); var sinon = require('sinon'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; @@ -224,7 +227,7 @@ describe('lib/core/project_config', function() { describe('projectConfig helper methods', function() { var testData = testDatafile.getTestProjectConfig(); var configObj; - var createdLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO}); + var createdLogger = loggerPlugin.createLogger({logLevel: LOG_LEVEL.INFO}); beforeEach(function() { configObj = projectConfig.createProjectConfig(testData); @@ -351,7 +354,7 @@ describe('lib/core/project_config', function() { }); describe('feature management', function() { - var featureManagementLogger = logger.createLogger({logLevel: LOG_LEVEL.INFO}); + var featureManagementLogger = loggerPlugin.createLogger({logLevel: LOG_LEVEL.INFO}); beforeEach(function() { configObj = projectConfig.createProjectConfig(testDatafile.getTestProjectConfigWithFeatures()); sinon.stub(featureManagementLogger, 'log'); @@ -559,7 +562,7 @@ describe('lib/core/project_config', function() { }); describe('#getForcedVariation', function() { - var createdLogger = logger.createLogger({ + var createdLogger = loggerPlugin.createLogger({ logLevel: LOG_LEVEL.INFO, logToConsole: false, }); @@ -582,7 +585,7 @@ describe('lib/core/project_config', function() { }); describe('#setForcedVariation', function() { - var createdLogger = logger.createLogger({ + var createdLogger = loggerPlugin.createLogger({ logLevel: LOG_LEVEL.INFO, logToConsole: false, }); @@ -730,26 +733,20 @@ describe('lib/core/project_config', function() { }); describe('#tryCreatingProjectConfig', function() { - var stubErrorHandler; var stubJsonSchemaValidator; - var stubLogger; beforeEach(function() { - stubErrorHandler = { - handleError: sinon.stub(), - }; stubJsonSchemaValidator = { validate: sinon.stub().returns(true), }; - stubLogger = { - log: sinon.stub(), - }; sinon.stub(projectConfig, 'createProjectConfig').returns({}); sinon.stub(configValidator, 'validateDatafile').returns(true); + sinon.spy(logger, 'error'); }); afterEach(function() { projectConfig.createProjectConfig.restore(); configValidator.validateDatafile.restore(); + logger.error.restore(); }); it('returns a project config object created by createProjectConfig when all validation is applied and there are no errors', function() { @@ -765,61 +762,44 @@ describe('lib/core/project_config', function() { projectConfig.createProjectConfig.returns(configObj); var result = projectConfig.tryCreatingProjectConfig({ datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, jsonSchemaValidator: stubJsonSchemaValidator, - logger: stubLogger, + logger: logger, skipJSONValidation: false, }); assert.deepEqual(result, configObj); }); - it('returns null and calls handleError when validateDatafile throws', function() { + it('throws an error when validateDatafile throws', function() { configValidator.validateDatafile.throws(); stubJsonSchemaValidator.validate.returns(true); - var result = projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, - jsonSchemaValidator: stubJsonSchemaValidator, - logger: stubLogger, - skipJSONValidation: false, + assert.throws(function() { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: false, + }); }); - assert.strictEqual(result, null); - sinon.assert.calledOnce(stubErrorHandler.handleError); }); - it('returns null and calls handleError when jsonSchemaValidator.validate throws', function() { + it('throws an error when jsonSchemaValidator.validate throws', function() { configValidator.validateDatafile.returns(true); stubJsonSchemaValidator.validate.throws(); - var result = projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, - jsonSchemaValidator: stubJsonSchemaValidator, - logger: stubLogger, - skipJSONValidation: false, - }); - assert.strictEqual(result, null); - sinon.assert.calledOnce(stubErrorHandler.handleError); - }); - - it('returns null when jsonSchemaValidator.validate returns false', function() { - configValidator.validateDatafile.returns(true); - stubJsonSchemaValidator.validate.returns(false); - var result = projectConfig.tryCreatingProjectConfig({ - datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, - jsonSchemaValidator: stubJsonSchemaValidator, - logger: stubLogger, - skipJSONValidation: false, + assert.throws(function() { + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: false, + }); }); - assert.strictEqual(result, null); }); it('does not call jsonSchemaValidator.validate when skipJSONValidation is true', function() { projectConfig.tryCreatingProjectConfig({ datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, jsonSchemaValidator: stubJsonSchemaValidator, - logger: stubLogger, + logger: logger, skipJSONValidation: true, }); sinon.assert.notCalled(stubJsonSchemaValidator.validate); @@ -837,11 +817,10 @@ describe('lib/core/project_config', function() { projectConfig.createProjectConfig.returns(configObj); var result = projectConfig.tryCreatingProjectConfig({ datafile: { foo: 'bar' }, - errorHandler: stubErrorHandler, - logger: stubLogger, + logger: logger, }); assert.deepEqual(result, configObj); - sinon.assert.notCalled(stubErrorHandler.handleError); + sinon.assert.notCalled(logger.error); }); }); }); diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js new file mode 100644 index 000000000..bd3499488 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -0,0 +1,255 @@ +/** + * 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 fns = require('../../utils/fns'); +var sprintf = require('@optimizely/js-sdk-utils').sprintf; +var logging = require('@optimizely/js-sdk-logging'); +var configValidator = require('../../utils/config_validator'); +var datafileManager = require('@optimizely/js-sdk-datafile-manager'); +var enums = require('../../utils/enums'); +var projectConfig = require('../../core/project_config'); + +var logger = logging.getLogger(); + +var ERROR_MESSAGES = enums.ERROR_MESSAGES; + +var MODULE_NAME = 'PROJECT_CONFIG_MANAGER'; + +/** + * ProjectConfigManager provides project config objects via its methods + * getConfig and onUpdate. It uses a DatafileManager to fetch datafiles. It is + * responsible for parsing and validating datafiles, and converting datafile + * JSON objects into project config objects. + * @param {Object} config + * @param {Object|string=} config.datafile + * @param {Object=} config.datafileOptions + * @param {Object=} config.jsonSchemaValidator + * @param {string=} config.sdkKey + * @param {boolean=} config.skipJSONValidation + */ +function ProjectConfigManager(config) { + try { + this.__initialize(config); + } catch (ex) { + logger.error(ex); + this.__updateListeners = []; + this.__configObj = null; + this.__readyPromise = Promise.reject(ex); + } +} + +/** + * Initialize internal properties including __updateListeners, __configObj, and + * __readyPromise, using the argument config. Create and subscribe to a datafile + * manager if appropriate. + * @param {Object} config + * @param {Object|string=} config.datafile + * @param {Object=} config.datafileOptions + * @param {Object=} config.jsonSchemaValidator + * @param {string=} config.sdkKey + * @param {boolean=} config.skipJSONValidation + */ +ProjectConfigManager.prototype.__initialize = function(config) { + this.__updateListeners = []; + this.jsonSchemaValidator = config.jsonSchemaValidator; + this.skipJSONValidation = config.skipJSONValidation; + + if (!config.datafile && !config.sdkKey) { + this.__configObj = null; + var datafileAndSdkKeyMissingError = new Error(sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME)); + this.__readyPromise = Promise.reject(datafileAndSdkKeyMissingError); + logger.error(datafileAndSdkKeyMissingError); + return; + } + + var initialDatafile = this.__getDatafileFromConfig(config); + var projectConfigCreationEx; + if (initialDatafile) { + try { + this.__configObj = projectConfig.tryCreatingProjectConfig({ + datafile: initialDatafile, + jsonSchemaValidator: this.jsonSchemaValidator, + logger: logger, + skipJSONValidation: this.skipJSONValidation, + }); + } catch (ex) { + logger.error(ex); + projectConfigCreationEx = ex; + this.__configObj = null; + } + } else { + this.__configObj = null; + } + + if (config.sdkKey) { + var datafileManagerConfig = { + sdkKey: config.sdkKey, + }; + if (this.__validateDatafileOptions(config.datafileOptions)) { + fns.assign(datafileManagerConfig, config.datafileOptions); + } + if (initialDatafile) { + datafileManagerConfig.datafile = initialDatafile; + } + this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig); + this.datafileManager.start(); + this.__readyPromise = this.datafileManager.onReady().then(this.__onDatafileManagerReady.bind(this)); + this.datafileManager.on('update', this.__onDatafileManagerUpdate.bind(this)); + } else if (this.__configObj) { + this.__readyPromise = Promise.resolve(); + } else { + this.__readyPromise = Promise.reject(projectConfigCreationEx); + } +}; + +/** + * Respond to datafile manager's onReady promise. When using a datafile manager, + * ProjectConfigManager's ready promise is based on DatafileManager's ready + * promise, and this function is used as a then callback. If this function + * throws (from validation failures), ProjectConfigManager's ready promise is + * rejected. Otherwise, ProjectConfigManager updates its own project config + * object from the new datafile, and calls its own registered update listeners. + */ +ProjectConfigManager.prototype.__onDatafileManagerReady = function() { + var newDatafile = this.datafileManager.get(); + var newConfigObj = projectConfig.tryCreatingProjectConfig({ + datafile: newDatafile, + jsonSchemaValidator: this.jsonSchemaValidator, + logger: logger, + skipJSONValidation: this.skipJSONValidation, + }); + this.__configObj = newConfigObj; + this.__updateListeners.forEach(function(listener) { + listener(newConfigObj); + }); +}; + +/** + * Respond to datafile manager's update event. Attempt to update own config + * object using latest datafile from datafile manager. Call own registered + * update listeners if successful + */ +ProjectConfigManager.prototype.__onDatafileManagerUpdate = function() { + var newDatafile = this.datafileManager.get(); + var newConfigObj; + try { + newConfigObj = projectConfig.tryCreatingProjectConfig({ + datafile: newDatafile, + jsonSchemaValidator: this.jsonSchemaValidator, + logger: logger, + skipJSONValidation: this.skipJSONValidation, + }); + } catch (ex) { + logger.error(ex); + } + if (newConfigObj) { + this.__configObj = newConfigObj; + this.__updateListeners.forEach(function(listener) { + listener(newConfigObj); + }); + } +}; + +/** + * If the argument config contains a valid datafile object or string, + * return a datafile object based on that provided datafile, otherwise + * return null. + * @param {Object} config + * @param {Object|string=} config.datafile + * @return {Object|null} + */ +ProjectConfigManager.prototype.__getDatafileFromConfig = function(config) { + var initialDatafile = null; + try { + if (config.datafile) { + configValidator.validateDatafile(config.datafile); + if (typeof config.datafile === 'string' || config.datafile instanceof String) { + initialDatafile = JSON.parse(config.datafile); + } else { + initialDatafile = config.datafile; + } + } + } catch (ex) { + logger.error(ex); + } + return initialDatafile; +}; + +/** + * Validate user-provided datafileOptions. It should be an object or undefined. + * @param {*} datafileOptions + * @returns {boolean} + */ +ProjectConfigManager.prototype.__validateDatafileOptions = function(datafileOptions) { + if (typeof datafileOptions === 'undefined') { + return true; + } + + if (typeof datafileOptions === 'object') { + return datafileOptions !== null; + } + + return false; +}; + +/** + * Returns the current project config object, or null if no project config object + * is available + * @return {Object|null} + */ +ProjectConfigManager.prototype.getConfig = function() { + return this.__configObj; +}; + +/** + * Returns a Promise that resolves when this ProjectConfigManager has a non-null + * project config object available for the first time. + * @return {Promise} + */ +ProjectConfigManager.prototype.onReady = function() { + return this.__readyPromise; +}; + +/** + * Add a listener for project config updates. The listener will be called + * whenever this instance has a new project config object available. + * Returns a dispose function that removes the subscription + * @param {Function} listener + * @return {Function} + */ +ProjectConfigManager.prototype.onUpdate = function(listener) { + this.__updateListeners.push(listener); + return function() { + var index = this.__updateListeners.indexOf(listener); + if (index > -1) { + this.__updateListeners.splice(index, 1); + } + }; +}; + +/** + * Stop the internal datafile manager and remove all update listeners + */ +ProjectConfigManager.prototype.stop = function() { + if (this.datafileManager) { + this.datafileManager.stop(); + } + this.__updateListeners = []; +}; + +module.exports = { + ProjectConfigManager: ProjectConfigManager, +}; diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js new file mode 100644 index 000000000..3bc2ee706 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -0,0 +1,265 @@ +/** + * 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 assert = require('chai').assert; +var datafileManager = require('@optimizely/js-sdk-datafile-manager'); +var logging = require('@optimizely/js-sdk-logging'); +var sinon = require('sinon'); +var sprintf = require('@optimizely/js-sdk-utils').sprintf; +var enums = require('../../utils/enums'); +var jsonSchemaValidator = require('../../utils/json_schema_validator'); +var projectConfig = require('./index'); +var projectConfigManager = require('./project_config_manager'); +var testData = require('../../tests/test_data'); + +var ERROR_MESSAGES = enums.ERROR_MESSAGES; +var LOG_MESSAGES = enums.LOG_MESSAGES; + +describe('lib/core/project_config/project_config_manager', function() { + var globalStubErrorHandler; + beforeEach(function() { + sinon.stub(datafileManager, 'DatafileManager').returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(null), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns({ then: function() {} }), + }); + globalStubErrorHandler = { + handleError: sinon.stub(), + }; + logging.setErrorHandler(globalStubErrorHandler); + logging.setLogLevel('notset'); + stubLogHandler = { + log: sinon.stub(), + }; + logging.setLogHandler(stubLogHandler); + }); + + afterEach(function() { + datafileManager.DatafileManager.restore(); + logging.resetErrorHandler(); + logging.resetLogger(); + }); + + it('should throw an error if neither datafile nor sdkKey are passed into the constructor', function(done) { + var manager = new projectConfigManager.ProjectConfigManager({ + skipJSONValidation: true, + }); + sinon.assert.calledOnce(globalStubErrorHandler.handleError); + var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; + assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'PROJECT_CONFIG_MANAGER')); + manager.onReady().catch(function() { + done(); + }); + }); + + it('should throw an error if the datafile JSON is malformed', function(done) { + var invalidDatafileJSON = 'abc'; + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: invalidDatafileJSON, + skipJSONValidation: true, + }); + sinon.assert.calledOnce(globalStubErrorHandler.handleError); + var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; + assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_MALFORMED, 'CONFIG_VALIDATOR')); + manager.onReady().catch(function() { + done(); + }); + }); + + it('should throw an error if the datafile is not valid', function(done) { + var invalidDatafile = testData.getTestProjectConfig(); + delete invalidDatafile['projectId']; + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: invalidDatafile, + jsonSchemaValidator: jsonSchemaValidator, + }); + sinon.assert.calledOnce(globalStubErrorHandler.handleError); + var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; + assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required')); + manager.onReady().catch(function() { + done(); + }); + }); + + it('should throw an error if the datafile version is not supported', function(done) { + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: testData.getUnsupportedVersionConfig(), + jsonSchemaValidator: jsonSchemaValidator, + }); + sinon.assert.calledOnce(globalStubErrorHandler.handleError); + var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; + assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5')); + manager.onReady().catch(function() { + done(); + }); + }); + + describe('skipping JSON schema validation', function() { + beforeEach(function() { + sinon.spy(jsonSchemaValidator, 'validate'); + }); + + afterEach(function() { + jsonSchemaValidator.validate.restore(); + }); + + it('should skip JSON schema validation if skipJSONValidation is passed into instance args with `true` value', function() { + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: testData.getTestProjectConfig(), + skipJSONValidation: true, + }); + sinon.assert.notCalled(jsonSchemaValidator.validate); + return manager.onReady(); + }); + + it('should not skip JSON schema validation if skipJSONValidation is passed into instance args with any value other than true', function() { + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: testData.getTestProjectConfig(), + jsonSchemaValidator: jsonSchemaValidator, + skipJSONValidation: 'hi', + }); + sinon.assert.calledOnce(jsonSchemaValidator.validate); + sinon.assert.calledOnce(stubLogHandler.log); + var logMessage = stubLogHandler.log.args[0][1]; + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); + return manager.onReady(); + }); + }); + + it('should return a valid datafile from getConfig and resolve onReady', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: configWithFeatures, + }); + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(configWithFeatures) + ); + return manager.onReady(); + }); + + describe('with a datafile manager', function() { + it('passes the correct options to datafile manager', function() { + new projectConfigManager.ProjectConfigManager({ + datafile: testData.getTestProjectConfig(), + sdkKey: '12345', + datafileOptions: { + autoUpdate: true, + updateInterval: 10000, + }, + }); + sinon.assert.calledOnce(datafileManager.DatafileManager); + sinon.assert.calledWithExactly(datafileManager.DatafileManager, sinon.match({ + datafile: testData.getTestProjectConfig(), + sdkKey: '12345', + autoUpdate: true, + updateInterval: 10000, + })); + }); + + it('updates itself when the datafile manager is ready and then emits updates', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(configWithFeatures), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + assert.isNull(manager.getConfig()); + return manager.onReady().then(function() { + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(configWithFeatures) + ); + + var nextDatafile = testData.getTestProjectConfigWithFeatures(); + nextDatafile.experiments.push({ + key: 'anotherTestExp', + status: 'Running', + forcedVariations: {}, + audienceIds: [], + layerId: '253442', + trafficAllocation: [{ entityId: '99977477477747747', endOfRange: 10000 }], + id: '1237847778', + variations: [{ key: 'variation', id: '99977477477747747' }], + }); + var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; + fakeDatafileManager.get.returns(nextDatafile); + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + updateListener({ datafile: nextDatafile }); + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(nextDatafile) + ); + }); + }); + + it('calls onUpdate listeners when the datafile manager is ready and emits updates', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + var onUpdateSpy = sinon.spy(); + manager.onUpdate(onUpdateSpy); + return manager.onReady().then(function() { + sinon.assert.calledOnce(onUpdateSpy); + var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + updateListener({ datafile: testData.getTestProjectConfigWithFeatures() }); + sinon.assert.calledTwice(onUpdateSpy); + }); + }); + + it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) { + var invalidDatafile = testData.getTestProjectConfig(); + delete invalidDatafile['projectId']; + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(invalidDatafile), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + jsonSchemaValidator: jsonSchemaValidator, + sdkKey: '12345', + }); + manager.onReady().catch(function() { + done(); + }); + }); + + it('calls stop on its datafile manager when its stop method is called', function() { + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + manager.stop(); + sinon.assert.calledOnce(datafileManager.DatafileManager.getCall(0).returnValue.stop); + }); + }); +}); diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index e7109c1f1..8c9328177 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -16,7 +16,6 @@ var fns = require('../utils/fns'); var attributesValidator = require('../utils/attributes_validator'); -var datafileManager = require('@optimizely/js-sdk-datafile-manager'); var decisionService = require('../core/decision_service'); var enums = require('../utils/enums'); var eventBuilder = require('../core/event_builder/index.js'); @@ -28,7 +27,7 @@ var projectConfig = require('../core/project_config'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; var userProfileServiceValidator = require('../utils/user_profile_service_validator'); var stringValidator = require('../utils/string_value_validator'); -var configValidator = require('../utils/config_validator'); +var projectConfigManager = require('../core/project_config/project_config_manager'); var ERROR_MESSAGES = enums.ERROR_MESSAGES; var LOG_LEVEL = enums.LOG_LEVEL; @@ -41,6 +40,7 @@ var NOTIFICATION_TYPES = enums.NOTIFICATION_TYPES; var DEFAULT_EVENT_MAX_QUEUE_SIZE = 1; var DEFAULT_EVENT_FLUSH_INTERVAL = 5000; +var DEFAULT_ONREADY_TIMEOUT = 30000; /** * The Optimizely class @@ -70,7 +70,20 @@ function Optimizely(config) { this.__isOptimizelyConfigValid = config.isValidInstance; this.logger = config.logger; - this.__initializeProjectConfigProperties(config); + this.projectConfigManager = new projectConfigManager.ProjectConfigManager({ + datafile: config.datafile, + datafileOptions: config.datafileOptions, + jsonSchemaValidator: config.jsonSchemaValidator, + sdkKey: config.sdkKey, + skipJSONValidation: config.skipJSONValidation, + }); + + this.__disposeOnUpdate = this.projectConfigManager.onUpdate(function(configObj) { + this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.UPDATED_PROJECT_CONFIG, MODULE_NAME, configObj.revision)); + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.PROJECT_CONFIG_UPDATE); + }.bind(this)); + + this.__readyPromise = this.projectConfigManager.onReady(); var userProfileService = null; if (config.userProfileService) { @@ -102,110 +115,6 @@ function Optimizely(config) { this.eventProcessor.start(); } -/** - * Initialize several properties, based on the argument config: - * - this.configObj: The project config object - * - this.datafileManager: A datafile manager that provides a ready promise - * and a stream of datafile update events - * - this.__readyPromise: Promise that will be fulfilled when this instance - * has a valid project config object for the first time - * The datafile and sdkKey provided in the argument config are used to - * initialize configObj, datafileManager, and __readyPromise. - * @param {Object} config - * @param {Object} config.datafile - * @param {Object} config.sdkKey - * @param {Object} config.jsonSchemaValidator - * @param {Object} config.skipJSONValidation - */ -Optimizely.prototype.__initializeProjectConfigProperties = function(config) { - try { - if (!config.datafile && !config.sdkKey) { - this.configObj = null; - this.__readyPromise = Promise.resolve(); - var datafileAndSdkKeyMissingError = new Error(sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME)); - this.logger.log(LOG_LEVEL.ERROR, datafileAndSdkKeyMissingError.message); - this.errorHandler.handleError(datafileAndSdkKeyMissingError); - return; - } - - var initialDatafile = this.__getDatafileFromConfig(config); - - if (config.sdkKey) { - var datafileManagerConfig = { - sdkKey: config.sdkKey, - }; - if (initialDatafile) { - datafileManagerConfig.datafile = initialDatafile; - } - this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig); - this.datafileManager.start(); - initialDatafile = this.datafileManager.get(); - } - - if (initialDatafile) { - this.configObj = projectConfig.tryCreatingProjectConfig({ - datafile: initialDatafile, - errorHandler: this.errorHandler, - jsonSchemaValidator: config.jsonSchemaValidator, - logger: this.logger, - skipJSONValidation: config.skipJSONValidation, - }); - } else { - this.configObj = null; - } - - if (this.datafileManager) { - this.__readyPromise = this.datafileManager.onReady().then(function() { - var newDatafile = this.datafileManager.get(); - var newConfigObj = projectConfig.tryCreatingProjectConfig({ - datafile: newDatafile, - errorHandler: this.errorHandler, - jsonSchemaValidator: config.jsonSchemaValidator, - logger: this.logger, - skipJSONValidation: config.skipJSONValidation, - }); - if (newConfigObj) { - this.configObj = newConfigObj; - this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.UPDATED_PROJECT_CONFIG, MODULE_NAME, this.configObj.revision)); - } - }.bind(this)); - } else { - this.__readyPromise = Promise.resolve(); - } - } catch (ex) { - this.configObj = null; - this.__readyPromise = Promise.resolve(); - this.logger.log(LOG_LEVEL.ERROR, ex.message); - this.errorHandler.handleError(ex); - } -}; - -/** - * If the argument config contains a valid datafile object or string, - * return a datafile object based on that provided datafile, otherwise - * return null. - * @param {Object} config - * @param {Object} config.datafile - * @return {Object|null} - */ -Optimizely.prototype.__getDatafileFromConfig = function(config) { - var initialDatafile = null; - try { - if (config.datafile) { - configValidator.validateDatafile(config.datafile); - if (typeof config.datafile === 'string' || config.datafile instanceof String) { - initialDatafile = JSON.parse(config.datafile); - } else { - initialDatafile = config.datafile; - } - } - } catch (ex) { - this.logger.log(LOG_LEVEL.ERROR, ex.message); - this.errorHandler.handleError(ex); - } - return initialDatafile; -}; - /** * Returns a truthy value if this instance currently has a valid project config * object, and the initial configuration object that was passed into the @@ -213,7 +122,7 @@ Optimizely.prototype.__getDatafileFromConfig = function(config) { * @return {*} */ Optimizely.prototype.__isValidInstance = function() { - return this.__isOptimizelyConfigValid && this.configObj; + return this.__isOptimizelyConfigValid && this.projectConfigManager.getConfig(); }; /** @@ -234,7 +143,7 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { return this.__notActivatingExperiment(experimentKey, userId); } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return null; } @@ -283,7 +192,7 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { * @param {Object} attributes Optional user attributes */ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey, userId, attributes) { - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return; } @@ -310,7 +219,7 @@ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey * @param {Object} attributes Optional user attributes */ Optimizely.prototype.__emitNotificationCenterActivate = function(experimentKey, variationKey, userId, attributes) { - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return; } @@ -367,7 +276,7 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { return; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return; } @@ -407,7 +316,7 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { */ Optimizely.prototype.__emitNotificationCenterTrack = function(eventKey, userId, attributes, eventTags) { try { - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return; } @@ -456,7 +365,7 @@ Optimizely.prototype.getVariation = function(experimentKey, userId, attributes) return null; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return null; } @@ -506,7 +415,7 @@ Optimizely.prototype.setForcedVariation = function(experimentKey, userId, variat return false; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return null; } @@ -531,7 +440,7 @@ Optimizely.prototype.getForcedVariation = function(experimentKey, userId) { return null; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return null; } @@ -630,7 +539,7 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) return false; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return false; } @@ -706,7 +615,7 @@ Optimizely.prototype.getEnabledFeatures = function(userId, attributes) { return enabledFeatures; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return enabledFeatures; } @@ -759,7 +668,7 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK return null; } - var configObj = this.configObj; + var configObj = this.projectConfigManager.getConfig(); if (!configObj) { return null; } @@ -936,6 +845,13 @@ Optimizely.prototype.getFeatureVariableString = function(featureKey, variableKey Optimizely.prototype.close = function() { try { this.eventProcessor.stop(); + if (this.__disposeOnUpdate) { + this.__disposeOnUpdate(); + this.__disposeOnUpdate = null; + } + if (this.projectConfigManager) { + this.projectConfigManager.stop(); + } } catch (e) { this.logger.log(LOG_LEVEL.ERROR, e.message); this.errorHandler.handleError(e); @@ -943,48 +859,30 @@ Optimizely.prototype.close = function() { }; /** - * Returns a Promise representing the readiness of this instance. - * - * Currently, readiness is based on the initial datafile fetch process, as well - * as the argument timeoutMs (if timeoutMs is provided). - * - * If no sdkKey was provided when the instance was created, then no datafile - * fetch will take place. This method will return an immediately fulfilled - * Promise. - * - * If timeoutMs is not provided, returns a Promise that fulfills immediately - * after the initial datafile fetch, parse, and project config object creation - * has completed. At this point, if the datafile provided by the datafile - * manager is valid and was successfully parsed into a project config object, - * this instance is ready to be used, and all entities from the datafile - * manager's datafile will be present in its project config object. - * - * If the initial datafile fetch failed, this promise will be rejected. - * - * If the initial datafile fetch succeeded, but the datafile was invalid or - * project config creation failed, this promise will be fulfilled, but this - * instance will continue to use whatever previous project config it had. If no - * previous project config was available, this instance will remain invalid. - * - * TODO: Mention autoUpdate after we implement it - * - * If a number timeoutMs argument is provided, the returned Promise will - * fullfill or reject as soon as one of the following happens: - * 1) Datafile fetch succeeds as described above (fulfills) - * 2) Datafile fetch fails as described above (rejects) - * 3) timeoutMs milliseconds have elapsed (fulfills) - * - * @param {number|undefined} timeoutMs + * Returns a Promise that resolves when this instance is ready to use (meaning + * it has a valid datafile), or has failed to become ready within a period of + * time (configurable by the timeout property of the options argument). If a + * valid datafile was provided in the constructor, the instance is immediately + * ready. If an sdkKey was provided, a manager will be used to fetch a datafile, + * and the returned promise will resolve if that fetch succeeds or fails before + * the timeout. The default timeout is 30 seconds, which will be used if no + * timeout is provided in the argument options object. + * @param {Object=} options + * @param {number|undefined} options.timeout * @return {Promise} */ -Optimizely.prototype.onReady = function(timeoutMs) { - if (!fns.isFinite(timeoutMs)) { - return this.__readyPromise; +Optimizely.prototype.onReady = function(options) { + var timeout; + if (typeof options === 'object' && options !== null) { + timeout = options.timeout; + } + if (!fns.isFinite(timeout)) { + timeout = DEFAULT_ONREADY_TIMEOUT; } var timeoutPromise = new Promise(function(resolve) { setTimeout(function() { resolve(); - }, timeoutMs); + }, timeout); }); return Promise.race([this.__readyPromise, timeoutPromise]); }; diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index ba75d933b..29a1cbef0 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -18,7 +18,7 @@ var Optimizely = require('./'); var audienceEvaluator = require('../core/audience_evaluator'); var bluebird = require('bluebird'); var bucketer = require('../core/bucketer'); -var datafileManager = require('@optimizely/js-sdk-datafile-manager'); +var projectConfigManager = require('../core/project_config/project_config_manager'); var enums = require('../utils/enums'); var eventBuilder = require('../core/event_builder/index.js'); var eventDispatcher = require('../plugins/event_dispatcher/index.node'); @@ -30,6 +30,7 @@ var decisionService = require('../core/decision_service'); var testData = require('../tests/test_data'); var jsonSchemaValidator = require('../utils/json_schema_validator'); var projectConfig = require('../core/project_config'); +var logging = require('@optimizely/js-sdk-logging'); var chai = require('chai'); var assert = chai.assert; @@ -45,21 +46,34 @@ var DECISION_INFO_TYPES = enums.DECISION_INFO_TYPES; var FEATURE_VARIABLE_TYPES = enums.FEATURE_VARIABLE_TYPES; describe('lib/optimizely', function() { - var DatafileManagerStub; + var ProjectConfigManagerStub; + var globalStubErrorHandler; + var stubLogHandler; beforeEach(function() { - DatafileManagerStub = sinon.stub(datafileManager, 'DatafileManager').callsFake(function() { + logging.setLogLevel('notset'); + stubLogHandler = { + log: sinon.stub(), + }; + logging.setLogHandler(stubLogHandler); + globalStubErrorHandler = { + handleError: sinon.stub(), + }; + logging.setErrorHandler(globalStubErrorHandler); + ProjectConfigManagerStub = sinon.stub(projectConfigManager, 'ProjectConfigManager').callsFake(function(config) { + var currentConfig = config.datafile ? projectConfig.createProjectConfig(config.datafile) : null; return { - start: sinon.stub(), stop: sinon.stub(), - get: sinon.stub().returns(null), - on: sinon.stub().returns(function() {}), + getConfig: sinon.stub().returns(currentConfig), + onUpdate: sinon.stub().returns(function() {}), onReady: sinon.stub().returns({ then: function() {} }) }; }); }); afterEach(function() { - DatafileManagerStub.restore(); + ProjectConfigManagerStub.restore(); + logging.resetErrorHandler(); + logging.resetLogger(); }); describe('constructor', function() { @@ -87,9 +101,6 @@ describe('lib/optimizely', function() { logger: createdLogger, }); assert.instanceOf(optlyInstance, Optimizely); - sinon.assert.called(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); }); it('should construct an instance of the Optimizely class when datafile is JSON string', function() { @@ -102,9 +113,6 @@ describe('lib/optimizely', function() { logger: createdLogger, }); assert.instanceOf(optlyInstance, Optimizely); - sinon.assert.called(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); }); it('should log if the client engine passed in is invalid', function() { @@ -120,117 +128,6 @@ describe('lib/optimizely', function() { assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.INVALID_CLIENT_ENGINE, 'OPTIMIZELY', 'undefined')); }); - it('should throw an error if neither datafile nor sdkKey are passed into the constructor', function() { - var optly = new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: stubErrorHandler, - logger: createdLogger, - }); - sinon.assert.calledOnce(stubErrorHandler.handleError); - var errorMessage = stubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'OPTIMIZELY')); - - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'OPTIMIZELY')); - - assert.isFalse(!!optly.__isValidInstance()); - }); - - it('should throw an error if the datafile JSON is malformed', function() { - var invalidDatafileJSON = 'abc'; - - new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: stubErrorHandler, - datafile: invalidDatafileJSON, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - }); - - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_MALFORMED, 'CONFIG_VALIDATOR')); - }); - - it('should throw an error if the datafile is not valid', function() { - var invalidDatafile = testData.getTestProjectConfig(); - delete invalidDatafile['projectId']; - - new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: stubErrorHandler, - datafile: invalidDatafile, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - }); - sinon.assert.calledOnce(stubErrorHandler.handleError); - var errorMessage = stubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required')); - - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required')); - }); - - it('should log an error if the datafile version is not supported', function() { - new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: stubErrorHandler, - datafile: testData.getUnsupportedVersionConfig(), - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - }); - - sinon.assert.calledOnce(stubErrorHandler.handleError); - var errorMessage = stubErrorHandler.handleError.lastCall.args[0].message; - assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5')); - - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5')); - }); - - describe('skipping JSON schema validation', function() { - beforeEach(function() { - sinon.spy(jsonSchemaValidator, 'validate'); - }); - - afterEach(function() { - jsonSchemaValidator.validate.restore(); - }); - - it('should skip JSON schema validation if skipJSONValidation is passed into instance args with `true` value', function() { - new Optimizely({ - clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - logger: logger.createLogger({ logToConsole: false }), - skipJSONValidation: true, - }); - - sinon.assert.notCalled(jsonSchemaValidator.validate); - }); - - it('should not skip JSON schema validation if skipJSONValidation is passed into instance args with any value other than true', function() { - new Optimizely({ - clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), - errorHandler: stubErrorHandler, - eventDispatcher: stubEventDispatcher, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - skipJSONValidation: 'hi', - }); - - sinon.assert.calledOnce(jsonSchemaValidator.validate); - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.VALID_DATAFILE, 'PROJECT_CONFIG')); - }); - }); - describe('when a user profile service is provided', function() { beforeEach(function() { sinon.stub(decisionService, 'createDecisionService'); @@ -259,8 +156,7 @@ describe('lib/optimizely', function() { logger: createdLogger, }); - // Checking the second log message as the first one just says "Datafile is valid" - var logMessage = createdLogger.log.args[1][1]; + var logMessage = createdLogger.log.args[0][1]; assert.strictEqual(logMessage, 'OPTIMIZELY: Valid user profile service provided.'); }); @@ -282,8 +178,7 @@ describe('lib/optimizely', function() { logger: createdLogger, }); - // Checking the second log message as the first one just says "Datafile is valid" - var logMessage = createdLogger.log.args[1][1]; + var logMessage = createdLogger.log.args[0][1]; assert.strictEqual(logMessage, 'USER_PROFILE_SERVICE_VALIDATOR: Provided user profile service instance is in an invalid format: Missing function \'lookup\'.'); }); }); @@ -304,26 +199,14 @@ describe('lib/optimizely', function() { sinon.assert.notCalled(stubErrorHandler.handleError); }); - it('creates and starts a datafile manager', function() { - new Optimizely({ - clientEngine: 'node-sdk', - eventBuilder: eventBuilder, - errorHandler: errorHandler, - eventDispatcher: eventDispatcher, - isValidInstance: true, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - sdkKey: '12345', - skipJSONValidation: false, - }); - sinon.assert.calledOnce(datafileManager.DatafileManager); - sinon.assert.calledOnce(datafileManager.DatafileManager.getCall(0).returnValue.start); - }); - - it('passes the sdkKey and the datafile to the datafile manager', function() { + it('passes datafile, datafileOptions, sdkKey, and other options to the project config manager', function() { new Optimizely({ clientEngine: 'node-sdk', datafile: testData.getTestProjectConfig(), + datafileOptions: { + autoUpdate: true, + updateInterval: 2 * 60 * 1000, + }, errorHandler: errorHandler, eventDispatcher: eventDispatcher, isValidInstance: true, @@ -332,76 +215,17 @@ describe('lib/optimizely', function() { sdkKey: '12345', skipJSONValidation: false, }); - sinon.assert.calledOnce(datafileManager.DatafileManager); - sinon.assert.calledWithExactly(datafileManager.DatafileManager, { + sinon.assert.calledOnce(projectConfigManager.ProjectConfigManager); + sinon.assert.calledWithExactly(projectConfigManager.ProjectConfigManager, { datafile: testData.getTestProjectConfig(), - sdkKey: '12345', - }); - }); - - it('passes only sdkKey to the datafile manager when no datafile was provided', function() { - new Optimizely({ - clientEngine: 'node-sdk', - errorHandler: errorHandler, - eventDispatcher: eventDispatcher, - isValidInstance: true, - jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, - sdkKey: '12345', - skipJSONValidation: false, - }); - sinon.assert.calledOnce(datafileManager.DatafileManager); - sinon.assert.calledWithExactly(datafileManager.DatafileManager, { - sdkKey: '12345', - }); - }); - - it('uses the datafile returned from the datafile manager get() for the immediately-available project config object', function() { - var differentDatafile = testData.getTestProjectConfig(); - differentDatafile.experiments.push({ - key: 'myOtherExperiment', - status: 'Running', - forcedVariations: { + datafileOptions: { + autoUpdate: true, + updateInterval: 2 * 60 * 1000, }, - audienceIds: [], - layerId: '5', - trafficAllocation: [ - { - entityId: '99999999', - endOfRange: 10000, - }, - ], - id: '999998888777776', - variations: [ - { - key: 'control', - id: '99999999', - }, - ], - }); - differentDatafile.revision = '44'; - datafileManager.DatafileManager.callsFake(function() { - return { - start: sinon.stub(), - stop: sinon.stub(), - // Return different datafile from get method -- this should be used immediately by Optimizely - get: sinon.stub().returns(differentDatafile), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns({ then: function() {} }) - }; - }); - var optlyInstance = new Optimizely({ - clientEngine: 'node-sdk', - datafile: testData.getTestProjectConfig(), - errorHandler: errorHandler, - eventDispatcher: eventDispatcher, - isValidInstance: true, jsonSchemaValidator: jsonSchemaValidator, - logger: createdLogger, sdkKey: '12345', skipJSONValidation: false, }); - assert.strictEqual(optlyInstance.activate('myOtherExperiment', 'user12345'), 'control'); }); }); }); @@ -2221,7 +2045,7 @@ describe('lib/optimizely', function() { enrich_decisions: true, }, }; - var instanceExperiments = optlyInstance.configObj.experiments; + var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], userId: 'testUser', @@ -2286,7 +2110,7 @@ describe('lib/optimizely', function() { enrich_decisions: true, }, }; - var instanceExperiments = optlyInstance.configObj.experiments; + var instanceExperiments = optlyInstance.projectConfigManager.getConfig().experiments; var expectedArgument = { experiment: instanceExperiments[0], userId: 'testUser', @@ -2604,7 +2428,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = optlyInstance.configObj.experimentKeyMap.testing_my_feature; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.testing_my_feature; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2633,7 +2457,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { beforeEach(function() { - var experiment = optlyInstance.configObj.experimentKeyMap.test_shared_feature; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.test_shared_feature; var variation = experiment.variations[1]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2665,7 +2489,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { // This experiment is the first audience targeting rule in the rollout of feature 'test_feature' - var experiment = optlyInstance.configObj.experimentKeyMap['594031']; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap['594031']; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2697,7 +2521,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { beforeEach(function() { // This experiment is the second audience targeting rule in the rollout of feature 'test_feature' - var experiment = optlyInstance.configObj.experimentKeyMap['594037']; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap['594037']; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2705,7 +2529,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('should return false and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1', { test_attribute: 'test_value', @@ -2738,7 +2562,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('should return false and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature', 'user1'); assert.strictEqual(result, false); @@ -2762,7 +2586,7 @@ describe('lib/optimizely', function() { describe('bucketed into variation of an experiment with variable values', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, 'testing_my_feature'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), 'testing_my_feature'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2770,7 +2594,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.EXPERIMENT, }); }); - + it('returns the right value from getFeatureVariableBoolean and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, true); @@ -2790,7 +2614,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the right value from getFeatureVariableDouble and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature_for_experiment', 'button_width', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 20.25); @@ -2810,7 +2634,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the right value from getFeatureVariableInteger and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature_for_experiment', 'num_buttons', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 2); @@ -2830,7 +2654,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the right value from getFeatureVariableString and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableString('test_feature_for_experiment', 'button_txt', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Buy me NOW'); @@ -2851,10 +2675,10 @@ describe('lib/optimizely', function() { }); }); }); - + describe('when the variation is toggled OFF', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, 'testing_my_feature'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), 'testing_my_feature'); var variation = experiment.variations[2]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2862,7 +2686,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.EXPERIMENT, }); }); - + it('returns the default value from getFeatureVariableBoolean and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, false); @@ -2882,7 +2706,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the default value from getFeatureVariableDouble and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature_for_experiment', 'button_width', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 50.55); @@ -2902,7 +2726,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the default value from getFeatureVariableInteger and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature_for_experiment', 'num_buttons', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 10); @@ -2922,7 +2746,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the default value from getFeatureVariableString and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableString('test_feature_for_experiment', 'button_txt', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Buy me'); @@ -2948,7 +2772,7 @@ describe('lib/optimizely', function() { describe('bucketed into variation of a rollout with variable values', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, '594031'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), '594031'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2956,7 +2780,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('should return the right value from getFeatureVariableBoolean and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature', 'new_content', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, true); @@ -2976,7 +2800,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the right value from getFeatureVariableDouble and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature', 'price', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 4.99); @@ -2996,7 +2820,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the right value from getFeatureVariableInteger and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature', 'lasers', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 395); @@ -3016,7 +2840,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the right value from getFeatureVariableString and send notification with featureEnabled true', function() { var result = optlyInstance.getFeatureVariableString('test_feature', 'message', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Hello audience'); @@ -3037,10 +2861,10 @@ describe('lib/optimizely', function() { }); }); }); - + describe('when the variation is toggled OFF', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, '594037'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), '594037'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3048,7 +2872,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('should return the default value from getFeatureVariableBoolean and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature', 'new_content', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, false); @@ -3068,7 +2892,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the default value from getFeatureVariableDouble and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature', 'price', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 14.99); @@ -3088,7 +2912,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the default value from getFeatureVariableInteger and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature', 'lasers', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 400); @@ -3108,7 +2932,7 @@ describe('lib/optimizely', function() { } }); }); - + it('should return the default value from getFeatureVariableString and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableString('test_feature', 'message', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Hello'); @@ -3139,7 +2963,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.ROLLOUT, }); }); - + it('returns the variable default value from getFeatureVariableBoolean and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, false); @@ -3159,7 +2983,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the variable default value from getFeatureVariableDouble and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature_for_experiment', 'button_width', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 50.55); @@ -3179,7 +3003,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the variable default value from getFeatureVariableInteger and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature_for_experiment', 'num_buttons', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 10); @@ -3199,7 +3023,7 @@ describe('lib/optimizely', function() { } }); }); - + it('returns the variable default value from getFeatureVariableString and send notification with featureEnabled false', function() { var result = optlyInstance.getFeatureVariableString('test_feature_for_experiment', 'button_txt', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Buy me'); @@ -3350,7 +3174,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = optlyInstance.configObj.experimentKeyMap.testing_my_feature; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.testing_my_feature; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3363,10 +3187,10 @@ describe('lib/optimizely', function() { var result = optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1', attributes); assert.strictEqual(result, true); sinon.assert.calledOnce(optlyInstance.decisionService.getVariationForFeature); - var feature = optlyInstance.configObj.featureKeyMap.test_feature_for_experiment; + var feature = optlyInstance.projectConfigManager.getConfig().featureKeyMap.test_feature_for_experiment; sinon.assert.calledWithExactly( optlyInstance.decisionService.getVariationForFeature, - optlyInstance.configObj, + optlyInstance.projectConfigManager.getConfig(), feature, 'user1', attributes @@ -3520,7 +3344,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { var result; beforeEach(function() { - var experiment = optlyInstance.configObj.experimentKeyMap.test_shared_feature; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.test_shared_feature; var variation = experiment.variations[1]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3533,10 +3357,10 @@ describe('lib/optimizely', function() { it('should return false', function() { assert.strictEqual(result, false); sinon.assert.calledOnce(optlyInstance.decisionService.getVariationForFeature); - var feature = optlyInstance.configObj.featureKeyMap.shared_feature; + var feature = optlyInstance.projectConfigManager.getConfig().featureKeyMap.shared_feature; sinon.assert.calledWithExactly( optlyInstance.decisionService.getVariationForFeature, - optlyInstance.configObj, + optlyInstance.projectConfigManager.getConfig(), feature, 'user1', attributes @@ -3603,7 +3427,7 @@ describe('lib/optimizely', function() { describe('when the variation is missing the toggle', function() { beforeEach(function() { - var experiment = optlyInstance.configObj.experimentKeyMap.test_shared_feature; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.test_shared_feature; var variation = fns.cloneDeep(experiment.variations[0]); delete variation['featureEnabled']; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ @@ -3617,10 +3441,10 @@ describe('lib/optimizely', function() { var result = optlyInstance.isFeatureEnabled('shared_feature', 'user1', attributes); assert.strictEqual(result, false); sinon.assert.calledOnce(optlyInstance.decisionService.getVariationForFeature); - var feature = optlyInstance.configObj.featureKeyMap.shared_feature; + var feature = optlyInstance.projectConfigManager.getConfig().featureKeyMap.shared_feature; sinon.assert.calledWithExactly( optlyInstance.decisionService.getVariationForFeature, - optlyInstance.configObj, + optlyInstance.projectConfigManager.getConfig(), feature, 'user1', attributes @@ -3633,7 +3457,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { // This experiment is the first audience targeting rule in the rollout of feature 'test_feature' - var experiment = optlyInstance.configObj.experimentKeyMap['594031']; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap['594031']; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3655,7 +3479,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { beforeEach(function() { // This experiment is the second audience targeting rule in the rollout of feature 'test_feature' - var experiment = optlyInstance.configObj.experimentKeyMap['594037']; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap['594037']; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3878,7 +3702,7 @@ describe('lib/optimizely', function() { describe('bucketed into variation in an experiment with variable values', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, 'testing_my_feature'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), 'testing_my_feature'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3915,25 +3739,25 @@ describe('lib/optimizely', function() { beforeEach(function() { sandbox.stub(projectConfig, 'getVariableValueForVariation').returns(null); }); - + it('returns the variable default value from getFeatureVariableBoolean', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, false); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "is_button_animated" is not used in variation "variation". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableDouble', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature_for_experiment', 'button_width', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 50.55); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "button_width" is not used in variation "variation". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableInteger', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature_for_experiment', 'num_buttons', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 10); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "num_buttons" is not used in variation "variation". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableString', function() { var result = optlyInstance.getFeatureVariableString('test_feature_for_experiment', 'button_txt', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Buy me'); @@ -3944,7 +3768,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, 'testing_my_feature'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), 'testing_my_feature'); var variation = experiment.variations[2]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -3982,7 +3806,7 @@ describe('lib/optimizely', function() { describe('bucketed into variation of a rollout with variable values', function() { describe('when the variation is toggled ON', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, '594031'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), '594031'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -4019,25 +3843,25 @@ describe('lib/optimizely', function() { beforeEach(function() { sandbox.stub(projectConfig, 'getVariableValueForVariation').returns(null); }); - + it('returns the variable default value from getFeatureVariableBoolean', function() { var result = optlyInstance.getFeatureVariableBoolean('test_feature', 'new_content', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, false); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "new_content" is not used in variation "594032". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableDouble', function() { var result = optlyInstance.getFeatureVariableDouble('test_feature', 'price', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 14.99); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "price" is not used in variation "594032". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableInteger', function() { var result = optlyInstance.getFeatureVariableInteger('test_feature', 'lasers', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 400); sinon.assert.calledWith(createdLogger.log, LOG_LEVEL.INFO, 'OPTIMIZELY: Variable "lasers" is not used in variation "594032". Returning default value.'); }); - + it('returns the variable default value from getFeatureVariableString', function() { var result = optlyInstance.getFeatureVariableString('test_feature', 'message', 'user1', { test_attribute: 'test_value' }); assert.strictEqual(result, 'Hello'); @@ -4048,7 +3872,7 @@ describe('lib/optimizely', function() { describe('when the variation is toggled OFF', function() { beforeEach(function() { - var experiment = projectConfig.getExperimentFromKey(optlyInstance.configObj, '594037'); + var experiment = projectConfig.getExperimentFromKey(optlyInstance.projectConfigManager.getConfig(), '594037'); var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -4529,8 +4353,8 @@ describe('lib/optimizely', function() { ); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.experiments[2].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().experiments[2].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, { house: 'Welcome to Slytherin!', lasers: 45.5 }, createdLogger ); @@ -4547,8 +4371,8 @@ describe('lib/optimizely', function() { sinon.assert.notCalled(eventDispatcher.dispatchEvent); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.experiments[2].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().experiments[2].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, { house: 'Hufflepuff', lasers: 45.5 }, createdLogger ); @@ -4581,8 +4405,8 @@ describe('lib/optimizely', function() { assert.isTrue(featureEnabled); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.rollouts[2].experiments[0].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().rollouts[2].experiments[0].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, { house: '...Slytherinnn...sss.', favorite_ice_cream: 'matcha' }, createdLogger ); @@ -4597,8 +4421,8 @@ describe('lib/optimizely', function() { assert.isFalse(featureEnabled); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.rollouts[2].experiments[0].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().rollouts[2].experiments[0].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, { house: 'Lannister' }, createdLogger ); @@ -4614,8 +4438,8 @@ describe('lib/optimizely', function() { assert.strictEqual(variableValue, 150); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.experiments[3].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().experiments[3].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, { house: 'Gryffindor', lasers: 700 }, createdLogger ); @@ -4628,8 +4452,8 @@ describe('lib/optimizely', function() { assert.strictEqual(variableValue, 10); sinon.assert.calledWithExactly( audienceEvaluator.evaluate, - optlyInstance.configObj.experiments[3].audienceConditions, - optlyInstance.configObj.audiencesById, + optlyInstance.projectConfigManager.getConfig().experiments[3].audienceConditions, + optlyInstance.projectConfigManager.getConfig().audiencesById, {}, createdLogger ); @@ -4898,7 +4722,7 @@ describe('lib/optimizely', function() { }); }); - describe('datafile management', function() { + describe('project config management', function() { var createdLogger = logger.createLogger({ logLevel: LOG_LEVEL.INFO, logToConsole: false, @@ -4918,6 +4742,21 @@ describe('lib/optimizely', function() { var optlyInstance; + it('should call the project config manager stop method when the close method is called', function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + optlyInstance.close(); + var fakeManager = projectConfigManager.ProjectConfigManager.getCall(0).returnValue; + sinon.assert.calledOnce(fakeManager.stop); + }); + describe('when no datafile is available yet ', function() { beforeEach(function() { optlyInstance = new Optimizely({ @@ -4954,25 +4793,16 @@ describe('lib/optimizely', function() { }); describe('onReady method', function() { - var datafileManagerOnReady; - var fulfillDatafileManagerOnReady; + var clock; beforeEach(function() { - var fakeDatafileManager = { - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(null), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub() - }; - datafileManagerOnReady = new Promise(function(fulfill) { - fulfillDatafileManagerOnReady = function(updatedConfig) { - fakeDatafileManager.get.returns(updatedConfig); - fulfill(); - }; - }); - fakeDatafileManager.onReady.returns(datafileManagerOnReady); - datafileManager.DatafileManager.returns(fakeDatafileManager); + clock = sinon.useFakeTimers(new Date().getTime()); + }); + afterEach(function() { + clock.restore(); + }); + + it('fulfills the promise after the timeout has expired when the project config manager onReady promise still has not resolved', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, @@ -4982,75 +4812,106 @@ describe('lib/optimizely', function() { sdkKey: '12345', isValidInstance: true, }); + var readyPromise = optlyInstance.onReady({ timeout: 500 }); + clock.tick(501); + return readyPromise; }); - afterEach(function() { - fulfillDatafileManagerOnReady(null); - return datafileManagerOnReady; + it('fulfills the promise after 30 seconds when no timeout argument is provided and the project config manager onReady promise still has not resolved', function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + var readyPromise = optlyInstance.onReady(); + clock.tick(300001); + return readyPromise; }); + }); - describe('feature management methods', function() { - var configWithFeatures = testData.getTestProjectConfigWithFeatures(); - beforeEach(function() { - var experiment = configWithFeatures.experiments[0]; - var variation = experiment.variations[0]; - sinon.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ - experiment: experiment, - variation: variation, - decisionSource: DECISION_SOURCES.EXPERIMENT, - }); - }); + describe('project config updates', function() { + var fakeProjectConfigManager; + beforeEach(function() { + fakeProjectConfigManager = { + stop: sinon.stub(), + getConfig: sinon.stub().returns(null), + onUpdate: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns({ then: function() {} }), + }; + projectConfigManager.ProjectConfigManager.returns(fakeProjectConfigManager); - it('updates the datafile it uses from the datafile manager get method after the datafile manager onReady promise fulfills', function() { - fulfillDatafileManagerOnReady(configWithFeatures); - return optlyInstance.onReady().then(function() { - assert.isTrue(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1')); - assert.strictEqual( - optlyInstance.getFeatureVariableBoolean('test_feature_for_experiment', 'is_button_animated', 'user1'), - true - ); - eventDispatcher.dispatchEvent.reset(); - optlyInstance.track('item_bought', 'user'); - sinon.assert.calledOnce(eventDispatcher.dispatchEvent); - }); + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, }); }); - describe('experimentation methods', function() { - var configObj = testData.getTestProjectConfig(); - beforeEach(function() { - sinon.stub(optlyInstance.decisionService, 'getVariation').returns('control'); - }); + // it('updates its project config object when the project config manager emits a new project config object', function() { + it('uses the newest project config object from project config manager', function() { + // Should start off returning false/null - no project config available + assert.isFalse(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user45678')); + assert.isNull(optlyInstance.activate('myOtherExperiment', 'user98765')); - it('updates the datafile it uses from the datafile manager get method after the datafile manager onReady promise fulfills', function() { - fulfillDatafileManagerOnReady(configObj); - return optlyInstance.onReady().then(function() { - assert.strictEqual( - optlyInstance.activate('testExperiment', 'user_123'), - 'control' - ); - eventDispatcher.dispatchEvent.reset(); - optlyInstance.track('testEvent', 'user_123'); - sinon.assert.calledOnce(eventDispatcher.dispatchEvent); - }); - }); - }); + // Project config manager receives new project config object - should use this now + var newConfig = projectConfig.createProjectConfig(testData.getTestProjectConfigWithFeatures()); + fakeProjectConfigManager.getConfig.returns(newConfig); + var updateListener = fakeProjectConfigManager.onUpdate.getCall(0).args[0]; + updateListener(newConfig); - describe('timeout', function() { - var clock; - beforeEach(function() { - clock = sinon.useFakeTimers(new Date().getTime()); - }); + // With the new project config containing this feature, should return true + assert.isTrue(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user45678')); - afterEach(function() { - clock.restore(); + // Update to another project config containing a new experiment + var differentDatafile = testData.getTestProjectConfigWithFeatures(); + differentDatafile.experiments.push({ + key: 'myOtherExperiment', + status: 'Running', + forcedVariations: { + }, + audienceIds: [], + layerId: '5', + trafficAllocation: [ + { + entityId: '99999999', + endOfRange: 10000, + }, + ], + id: '999998888777776', + variations: [ + { + key: 'control', + id: '99999999', + }, + ], }); + differentDatafile.revision = '44'; + var differentConfig = projectConfig.createProjectConfig(differentDatafile); + fakeProjectConfigManager.getConfig.returns(differentConfig); + updateListener(differentConfig); - it('fulfills the promise after the timeout has expired when the datafile manager promise still has not fulfilled', function() { - var readyPromise = optlyInstance.onReady(500); - clock.tick(501); - return readyPromise; - }); + // activate should return a variation for the new experiment + assert.strictEqual(optlyInstance.activate('myOtherExperiment', 'user98765'), 'control'); + }); + + it('emits a notification when the project config manager emits a new project config object', function() { + var listener = sinon.spy(); + optlyInstance.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.PROJECT_CONFIG_UPDATE, + listener + ); + var newConfig = projectConfig.createProjectConfig(testData.getTestProjectConfigWithFeatures()); + var updateListener = fakeProjectConfigManager.onUpdate.getCall(0).args[0]; + updateListener(newConfig); + sinon.assert.calledOnce(listener); }); }); }); diff --git a/packages/optimizely-sdk/lib/utils/enums/index.js b/packages/optimizely-sdk/lib/utils/enums/index.js index 656b6b0c5..b1fd30976 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.js +++ b/packages/optimizely-sdk/lib/utils/enums/index.js @@ -184,11 +184,18 @@ exports.NODE_CLIENT_VERSION = '3.1.0-beta1'; * - userId {string} * - attributes {Object|undefined} * - decisionInfo {Object|undefined} + * + * PROJECT_CONFIG_UPDATE: This Optimizely instance has been updated with a new + * project config + * Callbacks will receive an object argument with the following properties: + * - oldProjectConfig {Object} + * - newProjectConfig {Object|null} */ exports.NOTIFICATION_TYPES = { ACTIVATE: 'ACTIVATE:experiment, user_id,attributes, variation, event', TRACK: 'TRACK:event_key, user_id, attributes, event_tags, event', DECISION: 'DECISION:type, userId, attributes, decisionInfo', + PROJECT_CONFIG_UPDATE: 'PROJECT_CONFIG_UPDATE', }; exports.DECISION_INFO_TYPES = { From c3b6ce73969e26a9b7803f250d45cb9192d092b2 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 8 Apr 2019 11:34:00 -0700 Subject: [PATCH 16/42] fix (datafile manager): Small cleanups in datafile manager (#17) Summary: - Only assign current datafile if truthy (replace typeof datafile !== 'undefined' check with datafile check) - Change urlTemplate option to be a sprintf-style format string, like Java's withFormat - Change backoff delay to increase up to 512 seconds after 7 errors, and clean up the backoff implementation a little - Clean up url handling in HttpPollingConfigManager. It was saving more private variables than necessary for data that never changes after the constructor. Test plan: Updated unit tests --- .../__test__/backoffController.spec.ts | 22 ++++++++++------ .../httpPollingDatafileManager.spec.ts | 23 +++++++++++++++- packages/datafile-manager/package.json | 3 ++- .../datafile-manager/src/backoffController.ts | 7 ++--- packages/datafile-manager/src/config.ts | 8 ++---- .../src/httpPollingDatafileManager.ts | 26 +++++-------------- 6 files changed, 49 insertions(+), 40 deletions(-) diff --git a/packages/datafile-manager/__test__/backoffController.spec.ts b/packages/datafile-manager/__test__/backoffController.spec.ts index 07e576b4f..851030c5d 100644 --- a/packages/datafile-manager/__test__/backoffController.spec.ts +++ b/packages/datafile-manager/__test__/backoffController.spec.ts @@ -26,12 +26,6 @@ describe('backoffController', () => { it('increases the delay returned from getDelay (up to a maximum value) after each call to countError', () => { const controller = new BackoffController() controller.countError() - expect(controller.getDelay()).toBeGreaterThanOrEqual(2000) - expect(controller.getDelay()).toBeLessThan(3000) - controller.countError() - expect(controller.getDelay()).toBeGreaterThanOrEqual(4000) - expect(controller.getDelay()).toBeLessThan(5000) - controller.countError() expect(controller.getDelay()).toBeGreaterThanOrEqual(8000) expect(controller.getDelay()).toBeLessThan(9000) controller.countError() @@ -40,10 +34,22 @@ describe('backoffController', () => { controller.countError() expect(controller.getDelay()).toBeGreaterThanOrEqual(32000) expect(controller.getDelay()).toBeLessThan(33000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(64000) + expect(controller.getDelay()).toBeLessThan(65000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(128000) + expect(controller.getDelay()).toBeLessThan(129000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(256000) + expect(controller.getDelay()).toBeLessThan(257000) + controller.countError() + expect(controller.getDelay()).toBeGreaterThanOrEqual(512000) + expect(controller.getDelay()).toBeLessThan(513000) // Maximum reached - additional errors should not increase the delay further controller.countError() - expect(controller.getDelay()).toBeGreaterThanOrEqual(32000) - expect(controller.getDelay()).toBeLessThan(33000) + expect(controller.getDelay()).toBeGreaterThanOrEqual(512000) + expect(controller.getDelay()).toBeLessThan(513000) }) it('resets the error count when reset is called', () => { diff --git a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts index 955a83715..9b4e6f386 100644 --- a/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -555,7 +555,7 @@ describe('httpPollingDatafileManager', () => { manager = createTestManager({ sdkKey: '456', updateInterval: 1000, - urlTemplate: 'https://localhost:5556/datafiles/$SDK_KEY', + urlTemplate: 'https://localhost:5556/datafiles/%s', }) }) @@ -572,4 +572,25 @@ describe('httpPollingDatafileManager', () => { await manager.onReady() }) }) + + describe('when constructed with an update interval below the minimum', () => { + beforeEach(() => { + manager = createTestManager({ sdkKey: '123', updateInterval: 500, autoUpdate: true }) + }) + + it('uses the default update interval', async () => { + manager.queuedResponses.push({ + statusCode: 200, + body: '{"foo3": "bar3"}', + headers: {}, + }) + + const setTimeoutSpy: jest.SpyInstance<() => void, [() => void, number]> = jest.spyOn(testTimeoutFactory, 'setTimeout') + + manager.start() + await manager.onReady() + expect(setTimeoutSpy).toBeCalledTimes(1) + expect(setTimeoutSpy.mock.calls[0][1]).toBe(300000) + }) + }) }) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index 39455a783..ff7b6b11b 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -35,7 +35,8 @@ "typescript": "^3.3.3333" }, "dependencies": { - "@optimizely/js-sdk-logging": "^0.1.0" + "@optimizely/js-sdk-logging": "^0.1.0", + "@optimizely/js-sdk-utils": "^0.1.0" }, "scripts": { "test": "jest", diff --git a/packages/datafile-manager/src/backoffController.ts b/packages/datafile-manager/src/backoffController.ts index f08f583bd..a19650d2f 100644 --- a/packages/datafile-manager/src/backoffController.ts +++ b/packages/datafile-manager/src/backoffController.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT, - BACKOFF_MAX_ERROR_COUNT, -} from './config' +import { BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT } from './config' function randomMilliseconds() { return Math.round(Math.random() * 1000) @@ -38,7 +35,7 @@ export default class BackoffController { } countError(): void { - if (this.errorCount < BACKOFF_MAX_ERROR_COUNT) { + if (this.errorCount < BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1) { this.errorCount++ } } diff --git a/packages/datafile-manager/src/config.ts b/packages/datafile-manager/src/config.ts index 0dfc2c4d6..6bdb6aa25 100644 --- a/packages/datafile-manager/src/config.ts +++ b/packages/datafile-manager/src/config.ts @@ -18,10 +18,6 @@ export const DEFAULT_UPDATE_INTERVAL = 5 * 60 * 1000 // 5 minutes export const MIN_UPDATE_INTERVAL = 1000 -export const SDK_KEY_TOKEN = '$SDK_KEY' +export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/%s.json` -export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/${SDK_KEY_TOKEN}.json` - -export const BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT = [0, 2, 4, 8, 16, 32] - -export const BACKOFF_MAX_ERROR_COUNT = 10 +export const BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT = [0, 8, 16, 32, 64, 128, 256, 512] diff --git a/packages/datafile-manager/src/httpPollingDatafileManager.ts b/packages/datafile-manager/src/httpPollingDatafileManager.ts index eb3a40b04..8ce83b14d 100644 --- a/packages/datafile-manager/src/httpPollingDatafileManager.ts +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -15,10 +15,11 @@ */ import { getLogger } from '@optimizely/js-sdk-logging' +import { sprintf } from '@optimizely/js-sdk-utils'; import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'; import EventEmitter from './eventEmitter' import { AbortableRequest, Response, Headers } from './http'; -import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE, SDK_KEY_TOKEN } from './config' +import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE } from './config' import { TimeoutFactory, DEFAULT_TIMEOUT_FACTORY } from './timeoutFactory' import BackoffController from './backoffController'; @@ -46,8 +47,6 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private currentDatafile: object | null - private readonly sdkKey: string - private readonly readyPromise: Promise private isReadyPromiseSettled: boolean @@ -68,7 +67,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana private lastResponseLastModified?: string - private urlTemplate: string + private datafileUrl: string private timeoutFactory: TimeoutFactory @@ -90,8 +89,6 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana urlTemplate = DEFAULT_URL_TEMPLATE, } = configWithDefaultsApplied - this.sdkKey = sdkKey - this.isReadyPromiseSettled = false this.readyPromiseResolver = () => {} this.readyPromiseRejecter = () => {} @@ -100,7 +97,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.readyPromiseRejecter = reject }) - if (typeof datafile !== 'undefined') { + if (datafile) { this.currentDatafile = datafile this.resolveReadyPromise() } else { @@ -109,10 +106,7 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana this.isStarted = false - this.urlTemplate = urlTemplate - if (this.urlTemplate.indexOf(SDK_KEY_TOKEN) === -1) { - logger.debug(`urlTemplate does not contain replacement token ${SDK_KEY_TOKEN}`) - } + this.datafileUrl = sprintf(urlTemplate, sdkKey) this.timeoutFactory = timeoutFactory this.emitter = new EventEmitter() @@ -165,10 +159,6 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana return this.emitter.on(eventName, listener) } - private getUrl(sdkKey: string) { - return this.urlTemplate.replace(SDK_KEY_TOKEN, sdkKey) - } - private onRequestRejected(err: any): void { if (!this.isStarted) { return @@ -236,10 +226,8 @@ export default abstract class HTTPPollingDatafileManager implements DatafileMana headers['if-modified-since'] = this.lastResponseLastModified } - const datafileUrl = this.getUrl(this.sdkKey) - - logger.debug('Making datafile request to url %s with headers: %s', datafileUrl, () => JSON.stringify(headers)) - this.currentRequest = this.makeGetRequest(datafileUrl, headers) + logger.debug('Making datafile request to url %s with headers: %s', this.datafileUrl, () => JSON.stringify(headers)) + this.currentRequest = this.makeGetRequest(this.datafileUrl, headers) const onRequestComplete = () => { this.onRequestComplete() From 3287c367e1c26818db23283ff7a5f4d6f7dadd66 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Mon, 8 Apr 2019 17:52:58 -0700 Subject: [PATCH 17/42] fix (datafile manager): Pass hostname and port in request options (#18) Summary: Before this change, we were passing the host property returned from url.parse as the host option going into http.request (or https.request). The host property from url.parse includes the port when the URL contains a port. The problem is, request needs port to be passed as a separate option (not included as part of the host option). To fix, stop passing host, and replace it with hostname: url.hostname (where url.hostname excludes port), and port: url.port. References: https://nodejs.org/api/http.html#http_http_request_url_options_callback https://nodejs.org/api/url.html#url_urlobject_host https://nodejs.org/api/url.html#url_urlobject_hostname https://nodejs.org/api/url.html#url_urlobject_port Test plan: Added a regression test. Manually tested in @mikeng13's prototype datafile management branches for JS-testapp and compatibility suite. --- .../__test__/nodeRequest.spec.ts | 16 ++++++++++++++++ packages/datafile-manager/src/nodeRequest.ts | 5 +++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/datafile-manager/__test__/nodeRequest.spec.ts b/packages/datafile-manager/__test__/nodeRequest.spec.ts index 6e85ff4f7..7674ed50d 100644 --- a/packages/datafile-manager/__test__/nodeRequest.spec.ts +++ b/packages/datafile-manager/__test__/nodeRequest.spec.ts @@ -140,5 +140,21 @@ describe('nodeEnvironment', () => { await expect(req.responsePromise).rejects.toThrow() scope.done() }) + + it('handles a url with a host and a port', async () => { + const hostWithPort = 'http://datafiles:3000' + const path = '/12/345.json' + const scope = nock(hostWithPort) + .get(path) + .reply(200, '{"foo":"bar"}') + const req = makeGetRequest(`${hostWithPort}${path}`, {}) + const resp = await req.responsePromise + expect(resp).toEqual({ + statusCode: 200, + body: '{"foo":"bar"}', + headers: {} + }) + scope.done() + }) }) }) diff --git a/packages/datafile-manager/src/nodeRequest.ts b/packages/datafile-manager/src/nodeRequest.ts index 5caf25e08..ee0cfa2ff 100644 --- a/packages/datafile-manager/src/nodeRequest.ts +++ b/packages/datafile-manager/src/nodeRequest.ts @@ -24,9 +24,10 @@ type ClientRequestCreator = (options: http.RequestOptions) => http.ClientRequest function getRequestOptionsFromUrl(url: url.UrlWithStringQuery): http.RequestOptions { return { - protocol: url.protocol, - host: url.host, + hostname: url.hostname, path: url.path, + port: url.port, + protocol: url.protocol, } } From 8adfc7d9fadd0ab9f8052af22ac6cd899842a37f Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 9 Apr 2019 09:55:24 -0700 Subject: [PATCH 18/42] Fix README, remove unused enum --- packages/datafile-manager/README.md | 2 +- packages/datafile-manager/src/datafileManager.ts | 7 ------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/datafile-manager/README.md b/packages/datafile-manager/README.md index 9542b197d..1553e6f0f 100644 --- a/packages/datafile-manager/README.md +++ b/packages/datafile-manager/README.md @@ -5,7 +5,7 @@ This package provides a datafile manager implementations for Node.js and the bro ## Installation ```sh -npm install @optimizely/datafile-manager +npm install @optimizely/js-sdk-datafile-manager ``` ## Usage diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts index 49503479a..a1bb737e5 100644 --- a/packages/datafile-manager/src/datafileManager.ts +++ b/packages/datafile-manager/src/datafileManager.ts @@ -37,13 +37,6 @@ export interface DatafileManager extends Managed { onReady: () => Promise } -export enum CacheDirective { - // Use cache entry as fallback, but wait for CDN sync before onReady - AWAIT = 'await', - // Use cache entry for onReady, and do CDN sync in the background - DONT_AWAIT = 'dontawait', -} - export interface DatafileManagerConfig { autoUpdate?: boolean datafile?: object From 8db426d16106078be5973b041d60c28ef84581cb Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 9 Apr 2019 10:16:42 -0700 Subject: [PATCH 19/42] fix (datafile management): Fix unhandled onReady promise rejections in unit tests (#19) Summary: With changes for datafile management, we introduced Promise rejections that need to be handled. Add handlers to these expected errors. Test plan: Run unit tests with changes from this branch and master branch (which exits the test process on unhandled rejections) --- .../optimizely-sdk/lib/index.browser.tests.js | 15 ++++++++++++--- packages/optimizely-sdk/lib/index.node.tests.js | 10 ++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/optimizely-sdk/lib/index.browser.tests.js b/packages/optimizely-sdk/lib/index.browser.tests.js index 81d536aee..c01780ee8 100644 --- a/packages/optimizely-sdk/lib/index.browser.tests.js +++ b/packages/optimizely-sdk/lib/index.browser.tests.js @@ -74,21 +74,24 @@ describe('javascript-sdk', function() { }); it('should invoke resendPendingEvents at most once', function() { - optimizelyFactory.createInstance({ + var optlyInstance = optimizelyFactory.createInstance({ datafile: {}, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents); - optimizelyFactory.createInstance({ + optlyInstance = optimizelyFactory.createInstance({ datafile: {}, errorHandler: fakeErrorHandler, eventDispatcher: fakeEventDispatcher, logger: silentLogger, }); + optlyInstance.onReady().catch(function() {}); sinon.assert.calledOnce(LocalStoragePendingEventsDispatcher.prototype.sendPendingEvents); }); @@ -96,10 +99,12 @@ describe('javascript-sdk', function() { it('should not throw if the provided config is not valid', function() { configValidator.validate.throws(new Error('Invalid config or something')); assert.doesNotThrow(function() { - optimizelyFactory.createInstance({ + var optlyInstance = optimizelyFactory.createInstance({ datafile: {}, logger: silentLogger, }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); }); }); @@ -110,6 +115,8 @@ describe('javascript-sdk', function() { 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.1.0-beta1'); @@ -122,6 +129,8 @@ describe('javascript-sdk', function() { 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); }); diff --git a/packages/optimizely-sdk/lib/index.node.tests.js b/packages/optimizely-sdk/lib/index.node.tests.js index 67aee38fd..c8d6499b0 100644 --- a/packages/optimizely-sdk/lib/index.node.tests.js +++ b/packages/optimizely-sdk/lib/index.node.tests.js @@ -57,10 +57,12 @@ describe('optimizelyFactory', function() { configValidator.validate.throws(new Error('Invalid config or something')); var localLogger = loggerPlugin.createLogger({ logLevel: enums.LOG_LEVEL.INFO }); assert.doesNotThrow(function() { - optimizelyFactory.createInstance({ + var optlyInstance = optimizelyFactory.createInstance({ datafile: {}, logger: localLogger, }); + // Invalid datafile causes onReady Promise rejection - catch this + optlyInstance.onReady().catch(function() {}); }); sinon.assert.calledWith(localLogger.log, enums.LOG_LEVEL.ERROR); }); @@ -68,9 +70,11 @@ describe('optimizelyFactory', function() { it('should not throw if the provided config is not valid and log an error if no logger is provided', function() { configValidator.validate.throws(new Error('Invalid config or something')); assert.doesNotThrow(function() { - optimizelyFactory.createInstance({ + var optlyInstance = optimizelyFactory.createInstance({ datafile: {}, }); + // Invalid datafile causes onReady Promise rejection - catch this + optlyInstance.onReady().catch(function() {}); }); sinon.assert.calledOnce(console.error); }); @@ -82,6 +86,8 @@ describe('optimizelyFactory', function() { eventDispatcher: fakeEventDispatcher, logger: fakeLogger, }); + // Invalid datafile causes onReady Promise rejection - catch this + optlyInstance.onReady().catch(function() {}); assert.instanceOf(optlyInstance, Optimizely); assert.equal(optlyInstance.clientVersion, '3.1.0-beta1'); From b9c026f3c56e023a24c17e8de704eb2241aaceea Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 9 Apr 2019 10:45:09 -0700 Subject: [PATCH 20/42] Update CHANGELOG.md for v0.2.0 --- packages/datafile-manager/CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/datafile-manager/CHANGELOG.md b/packages/datafile-manager/CHANGELOG.md index e88980cae..97f4a7b22 100644 --- a/packages/datafile-manager/CHANGELOG.md +++ b/packages/datafile-manager/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] Changes that have landed but are not yet released. +## [0.2.0] - April 9, 2019 + +### Changed +- Increase max error count in backoff controller (can now delay requests for up to 512 seconds) [(#17)](https://github.com/optimizely/javascript-sdk-dev/pull/17) +- Change expected format of `urlTemplate` to be sprintf-compatible (`%s` is replaced with `sdkKey`) [(#17)](https://github.com/optimizely/javascript-sdk-dev/pull/17) +- Promise returned from `onReady` is resolved immediately when `datafile` provided in constructor [(#14)](https://github.com/optimizely/javascript-sdk-dev/pull/14) +- Emit update event whenever datafile changes, not only if `autoUpdate` is true [(#14)](https://github.com/optimizely/javascript-sdk-dev/pull/14) + +### Fixed + +- Fix for Node.js requests when `urlTemplate` contains a port [(#18)](https://github.com/optimizely/javascript-sdk-dev/pull/18) + ## [0.1.0] - March 4, 2019 Initial release From ec261d7971bcb2cc29916769ebe7cd759ac1545d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 9 Apr 2019 10:45:53 -0700 Subject: [PATCH 21/42] js-sdk-datafile-manager v0.2.0 --- packages/datafile-manager/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index ff7b6b11b..15d157c85 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -1,6 +1,6 @@ { "name": "@optimizely/js-sdk-datafile-manager", - "version": "0.1.0", + "version": "0.2.0", "description": "Optimizely Full Stack Datafile Manager", "license": "Apache-2.0", "engines": { From e02c569f918571afc1946ce6dc12065d0778f8f2 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 9 Apr 2019 10:57:26 -0700 Subject: [PATCH 22/42] Update js-sdk-datafile-manager version to 0.2.0 --- packages/optimizely-sdk/package-lock.json | 9 +++++---- packages/optimizely-sdk/package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/optimizely-sdk/package-lock.json b/packages/optimizely-sdk/package-lock.json index 621a43461..a5af3a986 100644 --- a/packages/optimizely-sdk/package-lock.json +++ b/packages/optimizely-sdk/package-lock.json @@ -5,11 +5,12 @@ "requires": true, "dependencies": { "@optimizely/js-sdk-datafile-manager": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.1.0.tgz", - "integrity": "sha512-y0nkFeUWUXOfoJVMgiaAFB+gyPl+v81PT621OdAa3Xd5sf/lOMcfalog3awcJGMlobVD5XwA5nDmdSZUGRLTJQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.2.0.tgz", + "integrity": "sha512-+Coj+0MRWEDGAmYfaDgTyzCT2FmFH1iOIL6W/h2zjTPPAfJBYrtcYdVllRIM8cXXNRvO89jSHmi2uknt0KX2Eg==", "requires": { - "@optimizely/js-sdk-logging": "^0.1.0" + "@optimizely/js-sdk-logging": "^0.1.0", + "@optimizely/js-sdk-utils": "^0.1.0" } }, "@optimizely/js-sdk-event-processor": { diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index febce1163..3c2b2aa2c 100644 --- a/packages/optimizely-sdk/package.json +++ b/packages/optimizely-sdk/package.json @@ -32,7 +32,7 @@ }, "homepage": "https://github.com/optimizely/javascript-sdk/tree/master/packages/optimizely-sdk", "dependencies": { - "@optimizely/js-sdk-datafile-manager": "^0.1.0", + "@optimizely/js-sdk-datafile-manager": "^0.2.0", "@optimizely/js-sdk-event-processor": "^0.2.0", "@optimizely/js-sdk-logging": "^0.1.0", "@optimizely/js-sdk-utils": "^0.1.0", From 309fc0142fb006d24323e6e6ef4320ec62c461de Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 10 Apr 2019 17:24:18 -0700 Subject: [PATCH 23/42] fix (project config manager): Only call update listener when datafile actually changes (#20) Summary: This includes two updates to project config manager: - When datafile manager provides a new datafile, only update project config object if that object has a different revision that the current config object - Only call update listeners if the config object actually changed With these two changes, we no longer trigger PROJECT_CONFIG_UPDATE notifications unless the project config actually changed. Previously, when constructed with a valid datafile and sdk key, it would trigger two PROJECT_CONFIG_UPDATE update notifications, even though the project config only changed once (after the fetch) Test plan: Updated unit tests --- .../project_config/project_config_manager.js | 31 ++- .../project_config_manager.tests.js | 198 +++++++++++------- 2 files changed, 144 insertions(+), 85 deletions(-) diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js index bd3499488..57146740d 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -131,10 +131,7 @@ ProjectConfigManager.prototype.__onDatafileManagerReady = function() { logger: logger, skipJSONValidation: this.skipJSONValidation, }); - this.__configObj = newConfigObj; - this.__updateListeners.forEach(function(listener) { - listener(newConfigObj); - }); + this.__handleNewConfigObj(newConfigObj); }; /** @@ -156,10 +153,7 @@ ProjectConfigManager.prototype.__onDatafileManagerUpdate = function() { logger.error(ex); } if (newConfigObj) { - this.__configObj = newConfigObj; - this.__updateListeners.forEach(function(listener) { - listener(newConfigObj); - }); + this.__handleNewConfigObj(newConfigObj); } }; @@ -205,6 +199,27 @@ ProjectConfigManager.prototype.__validateDatafileOptions = function(datafileOpti return false; }; +/** + * Update internal project config object to be argument object when the argument + * object has a different revision than the current internal project config + * object. If the internal object is updated, call update listeners. + * @param {Object} newConfigObj + */ +ProjectConfigManager.prototype.__handleNewConfigObj = function(newConfigObj) { + var oldConfigObj = this.__configObj; + + var oldRevision = oldConfigObj ? oldConfigObj.revision : 'null'; + if (oldRevision === newConfigObj.revision) { + return; + } + + this.__configObj = newConfigObj; + + this.__updateListeners.forEach(function(listener) { + listener(newConfigObj); + }); +}; + /** * Returns the current project config object, or null if no project config object * is available diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js index 3bc2ee706..137eef169 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -153,6 +153,18 @@ describe('lib/core/project_config/project_config_manager', function() { return manager.onReady(); }); + it('does not call onUpdate listeners after becoming ready when constructed with a valid datafile and without sdkKey', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: configWithFeatures, + }); + var onUpdateSpy = sinon.spy(); + manager.onUpdate(onUpdateSpy); + return manager.onReady().then(function() { + sinon.assert.notCalled(onUpdateSpy); + }); + }); + describe('with a datafile manager', function() { it('passes the correct options to datafile manager', function() { new projectConfigManager.ProjectConfigManager({ @@ -172,94 +184,126 @@ describe('lib/core/project_config/project_config_manager', function() { })); }); - it('updates itself when the datafile manager is ready and then emits updates', function() { - var configWithFeatures = testData.getTestProjectConfigWithFeatures(); - datafileManager.DatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(configWithFeatures), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()) - }); - var manager = new projectConfigManager.ProjectConfigManager({ - sdkKey: '12345', - }); - assert.isNull(manager.getConfig()); - return manager.onReady().then(function() { - assert.deepEqual( - manager.getConfig(), - projectConfig.createProjectConfig(configWithFeatures) - ); + describe('when constructed with sdkKey and without datafile', function() { + it('updates itself when the datafile manager is ready and then emits updates', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(configWithFeatures), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + assert.isNull(manager.getConfig()); + return manager.onReady().then(function() { + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(configWithFeatures) + ); - var nextDatafile = testData.getTestProjectConfigWithFeatures(); - nextDatafile.experiments.push({ - key: 'anotherTestExp', - status: 'Running', - forcedVariations: {}, - audienceIds: [], - layerId: '253442', - trafficAllocation: [{ entityId: '99977477477747747', endOfRange: 10000 }], - id: '1237847778', - variations: [{ key: 'variation', id: '99977477477747747' }], + var nextDatafile = testData.getTestProjectConfigWithFeatures(); + nextDatafile.experiments.push({ + key: 'anotherTestExp', + status: 'Running', + forcedVariations: {}, + audienceIds: [], + layerId: '253442', + trafficAllocation: [{ entityId: '99977477477747747', endOfRange: 10000 }], + id: '1237847778', + variations: [{ key: 'variation', id: '99977477477747747' }], + }); + nextDatafile.revision = '36'; + var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; + fakeDatafileManager.get.returns(nextDatafile); + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + updateListener({ datafile: nextDatafile }); + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(nextDatafile) + ); }); - var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; - fakeDatafileManager.get.returns(nextDatafile); - var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - updateListener({ datafile: nextDatafile }); - assert.deepEqual( - manager.getConfig(), - projectConfig.createProjectConfig(nextDatafile) - ); }); - }); - it('calls onUpdate listeners when the datafile manager is ready and emits updates', function() { - datafileManager.DatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()) - }); - var manager = new projectConfigManager.ProjectConfigManager({ - sdkKey: '12345', - }); - var onUpdateSpy = sinon.spy(); - manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function() { - sinon.assert.calledOnce(onUpdateSpy); - var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; - var updateListener = fakeDatafileManager.on.getCall(0).args[1]; - updateListener({ datafile: testData.getTestProjectConfigWithFeatures() }); - sinon.assert.calledTwice(onUpdateSpy); - }); - }); + it('calls onUpdate listeners after becoming ready, and after the datafile manager emits updates', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + var onUpdateSpy = sinon.spy(); + manager.onUpdate(onUpdateSpy); + return manager.onReady().then(function() { + sinon.assert.calledOnce(onUpdateSpy); - it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) { - var invalidDatafile = testData.getTestProjectConfig(); - delete invalidDatafile['projectId']; - datafileManager.DatafileManager.returns({ - start: sinon.stub(), - stop: sinon.stub(), - get: sinon.stub().returns(invalidDatafile), - on: sinon.stub().returns(function() {}), - onReady: sinon.stub().returns(Promise.resolve()) + var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + var newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '36'; + fakeDatafileManager.get.returns(newDatafile); + + updateListener({ datafile: newDatafile }); + sinon.assert.calledTwice(onUpdateSpy); + }); }); - var manager = new projectConfigManager.ProjectConfigManager({ - jsonSchemaValidator: jsonSchemaValidator, - sdkKey: '12345', + + it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) { + var invalidDatafile = testData.getTestProjectConfig(); + delete invalidDatafile['projectId']; + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(invalidDatafile), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + jsonSchemaValidator: jsonSchemaValidator, + sdkKey: '12345', + }); + manager.onReady().catch(function() { + done(); + }); }); - manager.onReady().catch(function() { - done(); + + it('calls stop on its datafile manager when its stop method is called', function() { + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + manager.stop(); + sinon.assert.calledOnce(datafileManager.DatafileManager.getCall(0).returnValue.stop); }); }); - it('calls stop on its datafile manager when its stop method is called', function() { - var manager = new projectConfigManager.ProjectConfigManager({ - sdkKey: '12345', + describe('when constructed with sdkKey and with a valid datafile', function() { + it('does not call onUpdate listeners after becoming ready', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: configWithFeatures, + sdkKey: '12345', + }); + var onUpdateSpy = sinon.spy(); + manager.onUpdate(onUpdateSpy); + return manager.onReady().then(function() { + // Datafile is the same as what it was constructed with, so should + // not have called update listener + sinon.assert.notCalled(onUpdateSpy); + }); }); - manager.stop(); - sinon.assert.calledOnce(datafileManager.DatafileManager.getCall(0).returnValue.stop); }); }); }); From 6c1b01422edf6bcfdf35881135ac68ef2145e775 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 11 Apr 2019 10:11:53 -0700 Subject: [PATCH 24/42] fix (project config manager): Fix onUpdate dispose function (#21) Summary: Fix dispose function returned from onUpdate of ProjectConfigManager. I forgot to bind the dispose function. Test plan: Added new unit test. Manually tested. --- .../project_config/project_config_manager.js | 2 +- .../project_config_manager.tests.js | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js index 57146740d..f8dc9ea07 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -252,7 +252,7 @@ ProjectConfigManager.prototype.onUpdate = function(listener) { if (index > -1) { this.__updateListeners.splice(index, 1); } - }; + }.bind(this); }; /** diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js index 137eef169..4183ade12 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -254,6 +254,42 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); + it('can remove onUpdate listeners using the function returned from onUpdate', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + sdkKey: '12345', + }); + return manager.onReady().then(function() { + var onUpdateSpy = sinon.spy(); + var unsubscribe = manager.onUpdate(onUpdateSpy); + + var fakeDatafileManager = datafileManager.DatafileManager.getCall(0).returnValue; + var updateListener = fakeDatafileManager.on.getCall(0).args[1]; + var newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '36'; + fakeDatafileManager.get.returns(newDatafile); + updateListener({ datafile: newDatafile }); + + sinon.assert.calledOnce(onUpdateSpy); + + unsubscribe(); + + newDatafile = testData.getTestProjectConfigWithFeatures(); + newDatafile.revision = '37'; + fakeDatafileManager.get.returns(newDatafile); + updateListener({ datafile: newDatafile }); + // // Should not call onUpdateSpy again since we unsubscribed + updateListener({ datafile: testData.getTestProjectConfigWithFeatures() }); + sinon.assert.calledOnce(onUpdateSpy); + }); + }); + it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) { var invalidDatafile = testData.getTestProjectConfig(); delete invalidDatafile['projectId']; From b6884e9ebee29df26b619403e4b92cd48085bcc6 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 11 Apr 2019 14:18:56 -0700 Subject: [PATCH 25/42] Fix umd bundle tests --- packages/optimizely-sdk/lib/index.browser.umdtests.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/optimizely-sdk/lib/index.browser.umdtests.js b/packages/optimizely-sdk/lib/index.browser.umdtests.js index 32181928e..9738a0667 100644 --- a/packages/optimizely-sdk/lib/index.browser.umdtests.js +++ b/packages/optimizely-sdk/lib/index.browser.umdtests.js @@ -73,7 +73,7 @@ describe('javascript-sdk', function() { assert.strictEqual(console.info.getCalls().length, 1); call = console.info.getCalls()[0]; assert.strictEqual(call.args.length, 1); - assert(call.args[0].indexOf('OPTIMIZELY: Skipping JSON schema validation.') > -1); + assert(call.args[0].indexOf('PROJECT_CONFIG: Skipping JSON schema validation.') > -1); }); it('should instantiate the logger with a custom logLevel when provided', function() { @@ -92,15 +92,19 @@ describe('javascript-sdk', function() { }); optlyInstance.activate('testExperiment', 'testUser'); assert.strictEqual(console.error.getCalls().length, 1); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstanceInvalid.onReady().catch(function() {}); }); it('should not throw if the provided config is not valid', function() { configValidator.validate.throws(new Error('Invalid config or something')); assert.doesNotThrow(function() { - window.optimizelySdk.createInstance({ + var optlyInstance = window.optimizelySdk.createInstance({ datafile: {}, logger: silentLogger, }); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); }); }); @@ -113,6 +117,8 @@ describe('javascript-sdk', function() { }); assert.equal('javascript-sdk', optlyInstance.clientEngine); assert.equal(packageJSON.version, optlyInstance.clientVersion); + // Invalid datafile causes onReady Promise rejection - catch this error + optlyInstance.onReady().catch(function() {}); }); it('should activate with provided event dispatcher', function() { From 67105bae22e7bff9da914d8fc7187936ea564d51 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 16 Apr 2019 10:26:31 -0700 Subject: [PATCH 26/42] fix (config update): Change notification name and log message to refer to Optimizely config instead of project config (#22) Summary: Notification name and log messages refer to "Optimizely config" instead of "project config". Test plan: Updated unit test --- packages/optimizely-sdk/lib/optimizely/index.js | 6 +++--- .../optimizely-sdk/lib/optimizely/index.tests.js | 2 +- packages/optimizely-sdk/lib/utils/enums/index.js | 13 +++++-------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index 8c9328177..8c2ef2d78 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -79,8 +79,8 @@ function Optimizely(config) { }); this.__disposeOnUpdate = this.projectConfigManager.onUpdate(function(configObj) { - this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.UPDATED_PROJECT_CONFIG, MODULE_NAME, configObj.revision)); - this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.PROJECT_CONFIG_UPDATE); + this.logger.log(LOG_LEVEL.INFO, sprintf(LOG_MESSAGES.UPDATED_OPTIMIZELY_CONFIG, MODULE_NAME, configObj.revision, configObj.projectId)); + this.notificationCenter.sendNotifications(NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE); }.bind(this)); this.__readyPromise = this.projectConfigManager.onReady(); @@ -694,7 +694,7 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK var featureEnabled = false; var variableValue = variable.defaultValue; var decision = this.decisionService.getVariationForFeature(configObj, featureFlag, userId, attributes); - + if (decision.variation !== null) { featureEnabled = decision.variation.featureEnabled; var value = projectConfig.getVariableValueForVariation(configObj, variable, decision.variation, this.logger); diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 1b6f6168c..b2446cff1 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -4906,7 +4906,7 @@ describe('lib/optimizely', function() { it('emits a notification when the project config manager emits a new project config object', function() { var listener = sinon.spy(); optlyInstance.notificationCenter.addNotificationListener( - enums.NOTIFICATION_TYPES.PROJECT_CONFIG_UPDATE, + enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, listener ); var newConfig = projectConfig.createProjectConfig(testData.getTestProjectConfigWithFeatures()); diff --git a/packages/optimizely-sdk/lib/utils/enums/index.js b/packages/optimizely-sdk/lib/utils/enums/index.js index b1fd30976..2249fbab0 100644 --- a/packages/optimizely-sdk/lib/utils/enums/index.js +++ b/packages/optimizely-sdk/lib/utils/enums/index.js @@ -136,7 +136,7 @@ exports.LOG_MESSAGES = { UNEXPECTED_TYPE_NULL: '%s: Audience condition %s evaluated to UNKNOWN because a null value was passed for user attribute "%s".', UNKNOWN_CONDITION_TYPE: '%s: Audience condition %s has an unknown condition type. You may need to upgrade to a newer release of the Optimizely SDK.', UNKNOWN_MATCH_TYPE: '%s: Audience condition %s uses an unknown match type. You may need to upgrade to a newer release of the Optimizely SDK.', - UPDATED_PROJECT_CONFIG: '%s: Updated project config to revision %s', + UPDATED_OPTIMIZELY_CONFIG: '%s: Updated Optimizely config to revision %s (project id %s)', OUT_OF_BOUNDS: '%s: Audience condition %s evaluated to UNKNOWN because the number value for user attribute "%s" is not in the range [-2^53, +2^53].', }; @@ -177,7 +177,7 @@ exports.NODE_CLIENT_VERSION = '3.1.0-beta1'; * - attributes {Object|undefined} * - eventTags {Object|undefined} * - logEvent {Object} - * DECISION: A decision is made in the system. i.e. user activation, + * DECISION: A decision is made in the system. i.e. user activation, * feature access or feature-variable value retrieval * Callbacks will receive an object argument with the following properties: * - type {string} @@ -185,17 +185,14 @@ exports.NODE_CLIENT_VERSION = '3.1.0-beta1'; * - attributes {Object|undefined} * - decisionInfo {Object|undefined} * - * PROJECT_CONFIG_UPDATE: This Optimizely instance has been updated with a new - * project config - * Callbacks will receive an object argument with the following properties: - * - oldProjectConfig {Object} - * - newProjectConfig {Object|null} + * OPTIMIZELY_CONFIG_UPDATE: This Optimizely instance has been updated with a new + * config */ exports.NOTIFICATION_TYPES = { ACTIVATE: 'ACTIVATE:experiment, user_id,attributes, variation, event', TRACK: 'TRACK:event_key, user_id, attributes, event_tags, event', DECISION: 'DECISION:type, userId, attributes, decisionInfo', - PROJECT_CONFIG_UPDATE: 'PROJECT_CONFIG_UPDATE', + OPTIMIZELY_CONFIG_UPDATE: 'OPTIMIZELY_CONFIG_UPDATE', }; exports.DECISION_INFO_TYPES = { From d95aee55789f370437b8dd2a6f3184ace85ed8a0 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 17 Apr 2019 09:57:08 -0700 Subject: [PATCH 27/42] fix (project config manager & Optimizely): Always fulfill onReady Promise with a result object, instead of sometimes rejecting (#23) Summary: When documenting datafile management, I realized the rationale behind the behavior of the promise returned from Optimizely onReady was inconsistent. It would sometimes fulfill even if the instance was still not ready to be used. Also, I thought it would be better to always fulfill because we attempt to never throw errors, and rejected promises are akin to thrown errors. Before this change, the promise would fulfill either when a valid project config was obtained prior to the timeout, or when the timeout expired, and reject in other cases. With this change, the promise always fulfills with a result object of the form { success: boolean, reason?: string }. When success is true, the instance has a valid project config and is ready to use. When success is false, the instance failed to become ready, and reason explains why. Test plan: Updated unit tests --- .../project_config/project_config_manager.js | 93 ++++++++++++--- .../project_config_manager.tests.js | 110 ++++++++++++++---- .../optimizely-sdk/lib/optimizely/index.js | 17 ++- .../lib/optimizely/index.tests.js | 17 ++- 4 files changed, 189 insertions(+), 48 deletions(-) diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js index f8dc9ea07..ad78377db 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -28,6 +28,21 @@ var ERROR_MESSAGES = enums.ERROR_MESSAGES; var MODULE_NAME = 'PROJECT_CONFIG_MANAGER'; +/** + * Return an error message derived from a thrown value. If the thrown value is + * an error, return the error's message property. Otherwise, return a default + * provided by the second argument. + * @param {*} maybeError + * @param {String=} defaultMessage + * @return {String} + */ +function getErrorMessage(maybeError, defaultMessage) { + if (maybeError instanceof Error) { + return maybeError.message; + } + return defaultMessage || 'Unknown error'; +} + /** * ProjectConfigManager provides project config objects via its methods * getConfig and onUpdate. It uses a DatafileManager to fetch datafiles. It is @@ -47,7 +62,10 @@ function ProjectConfigManager(config) { logger.error(ex); this.__updateListeners = []; this.__configObj = null; - this.__readyPromise = Promise.reject(ex); + this.__readyPromise = Promise.resolve({ + success: false, + reason: getErrorMessage(ex, 'Error in initialize'), + }); } } @@ -70,7 +88,10 @@ ProjectConfigManager.prototype.__initialize = function(config) { if (!config.datafile && !config.sdkKey) { this.__configObj = null; var datafileAndSdkKeyMissingError = new Error(sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, MODULE_NAME)); - this.__readyPromise = Promise.reject(datafileAndSdkKeyMissingError); + this.__readyPromise = Promise.resolve({ + success: false, + reason: getErrorMessage(datafileAndSdkKeyMissingError), + }); logger.error(datafileAndSdkKeyMissingError); return; } @@ -101,37 +122,71 @@ ProjectConfigManager.prototype.__initialize = function(config) { if (this.__validateDatafileOptions(config.datafileOptions)) { fns.assign(datafileManagerConfig, config.datafileOptions); } - if (initialDatafile) { + if (initialDatafile && this.__configObj) { datafileManagerConfig.datafile = initialDatafile; } this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig); this.datafileManager.start(); - this.__readyPromise = this.datafileManager.onReady().then(this.__onDatafileManagerReady.bind(this)); + this.__readyPromise = this.datafileManager.onReady().then( + this.__onDatafileManagerReadyFulfill.bind(this), + this.__onDatafileManagerReadyReject.bind(this) + ); this.datafileManager.on('update', this.__onDatafileManagerUpdate.bind(this)); } else if (this.__configObj) { - this.__readyPromise = Promise.resolve(); + this.__readyPromise = Promise.resolve({ + success: true, + }); } else { - this.__readyPromise = Promise.reject(projectConfigCreationEx); + this.__readyPromise = Promise.resolve({ + success: false, + reason: getErrorMessage(projectConfigCreationEx, 'Invalid datafile'), + }); } }; /** - * Respond to datafile manager's onReady promise. When using a datafile manager, - * ProjectConfigManager's ready promise is based on DatafileManager's ready - * promise, and this function is used as a then callback. If this function - * throws (from validation failures), ProjectConfigManager's ready promise is - * rejected. Otherwise, ProjectConfigManager updates its own project config - * object from the new datafile, and calls its own registered update listeners. + * Respond to datafile manager's onReady promise becoming fulfilled. + * If there are validation or parse failures using the datafile provided by + * DatafileManager, ProjectConfigManager's ready promise is resolved with an + * unsuccessful result. Otherwise, ProjectConfigManager updates its own project + * config object from the new datafile, and its ready promise is resolved with a + * successful result. */ -ProjectConfigManager.prototype.__onDatafileManagerReady = function() { +ProjectConfigManager.prototype.__onDatafileManagerReadyFulfill = function() { var newDatafile = this.datafileManager.get(); - var newConfigObj = projectConfig.tryCreatingProjectConfig({ - datafile: newDatafile, - jsonSchemaValidator: this.jsonSchemaValidator, - logger: logger, - skipJSONValidation: this.skipJSONValidation, - }); + var newConfigObj; + try { + newConfigObj = projectConfig.tryCreatingProjectConfig({ + datafile: newDatafile, + jsonSchemaValidator: this.jsonSchemaValidator, + logger: logger, + skipJSONValidation: this.skipJSONValidation, + }); + } catch (ex) { + logger.error(ex); + return { + success: false, + reason: getErrorMessage(ex), + }; + } this.__handleNewConfigObj(newConfigObj); + return { + success: true, + }; +}; + +/** + * Respond to datafile manager's onReady promise becoming rejected. + * When DatafileManager's onReady promise is rejected, there is no possibility + * of obtaining a datafile. In this case, ProjectConfigManager's ready promise + * is fulfilled with an unsuccessful result. + * @param {Error} err + */ +ProjectConfigManager.prototype.__onDatafileManagerReadyReject = function(err) { + return { + success: false, + reason: getErrorMessage(err, 'Failed to become ready'), + }; }; /** diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js index 4183ade12..b405b83e4 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -55,19 +55,21 @@ describe('lib/core/project_config/project_config_manager', function() { logging.resetLogger(); }); - it('should throw an error if neither datafile nor sdkKey are passed into the constructor', function(done) { + it('should call the error handler and fulfill onReady with an unsuccessful result if neither datafile nor sdkKey are passed into the constructor', function() { var manager = new projectConfigManager.ProjectConfigManager({ skipJSONValidation: true, }); sinon.assert.calledOnce(globalStubErrorHandler.handleError); var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.DATAFILE_AND_SDK_KEY_MISSING, 'PROJECT_CONFIG_MANAGER')); - manager.onReady().catch(function() { - done(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); }); }); - it('should throw an error if the datafile JSON is malformed', function(done) { + it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile JSON is malformed', function() { var invalidDatafileJSON = 'abc'; var manager = new projectConfigManager.ProjectConfigManager({ datafile: invalidDatafileJSON, @@ -76,12 +78,14 @@ describe('lib/core/project_config/project_config_manager', function() { sinon.assert.calledOnce(globalStubErrorHandler.handleError); var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_MALFORMED, 'CONFIG_VALIDATOR')); - manager.onReady().catch(function() { - done(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); }); }); - it('should throw an error if the datafile is not valid', function(done) { + it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile is not valid', function() { var invalidDatafile = testData.getTestProjectConfig(); delete invalidDatafile['projectId']; var manager = new projectConfigManager.ProjectConfigManager({ @@ -91,12 +95,14 @@ describe('lib/core/project_config/project_config_manager', function() { sinon.assert.calledOnce(globalStubErrorHandler.handleError); var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE, 'JSON_SCHEMA_VALIDATOR', 'projectId', 'is missing and it is required')); - manager.onReady().catch(function() { - done(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); }); }); - it('should throw an error if the datafile version is not supported', function(done) { + it('should call the error handler and fulfill onReady with an unsuccessful result if the datafile version is not supported', function() { var manager = new projectConfigManager.ProjectConfigManager({ datafile: testData.getUnsupportedVersionConfig(), jsonSchemaValidator: jsonSchemaValidator, @@ -104,8 +110,10 @@ describe('lib/core/project_config/project_config_manager', function() { sinon.assert.calledOnce(globalStubErrorHandler.handleError); var errorMessage = globalStubErrorHandler.handleError.lastCall.args[0].message; assert.strictEqual(errorMessage, sprintf(ERROR_MESSAGES.INVALID_DATAFILE_VERSION, 'CONFIG_VALIDATOR', '5')); - manager.onReady().catch(function() { - done(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); }); }); @@ -141,7 +149,7 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); - it('should return a valid datafile from getConfig and resolve onReady', function() { + it('should return a valid datafile from getConfig and resolve onReady with a successful result', function() { var configWithFeatures = testData.getTestProjectConfigWithFeatures(); var manager = new projectConfigManager.ProjectConfigManager({ datafile: configWithFeatures, @@ -150,7 +158,11 @@ describe('lib/core/project_config/project_config_manager', function() { manager.getConfig(), projectConfig.createProjectConfig(configWithFeatures) ); - return manager.onReady(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: true, + }); + }); }); it('does not call onUpdate listeners after becoming ready when constructed with a valid datafile and without sdkKey', function() { @@ -185,7 +197,7 @@ describe('lib/core/project_config/project_config_manager', function() { }); describe('when constructed with sdkKey and without datafile', function() { - it('updates itself when the datafile manager is ready and then emits updates', function() { + it('updates itself when the datafile manager is ready, fulfills its onReady promise with a successful result, and then emits updates', function() { var configWithFeatures = testData.getTestProjectConfigWithFeatures(); datafileManager.DatafileManager.returns({ start: sinon.stub(), @@ -198,7 +210,10 @@ describe('lib/core/project_config/project_config_manager', function() { sdkKey: '12345', }); assert.isNull(manager.getConfig()); - return manager.onReady().then(function() { + return manager.onReady().then(function(result) { + assert.include(result, { + success: true, + }); assert.deepEqual( manager.getConfig(), projectConfig.createProjectConfig(configWithFeatures) @@ -290,7 +305,7 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); - it('rejects its ready promise when the datafile manager emits an invalid datafile', function(done) { + it('fulfills its ready promise with an unsuccessful result when the datafile manager emits an invalid datafile', function() { var invalidDatafile = testData.getTestProjectConfig(); delete invalidDatafile['projectId']; datafileManager.DatafileManager.returns({ @@ -304,8 +319,29 @@ describe('lib/core/project_config/project_config_manager', function() { jsonSchemaValidator: jsonSchemaValidator, sdkKey: '12345', }); - manager.onReady().catch(function() { - done(); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + it('fullfils its ready promise with an unsuccessful result when the datafile manager onReady promise rejects', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(null), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.reject(new Error('Failed to become ready'))) + }); + var manager = new projectConfigManager.ProjectConfigManager({ + jsonSchemaValidator: jsonSchemaValidator, + sdkKey: '12345', + }); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); }); }); @@ -318,8 +354,8 @@ describe('lib/core/project_config/project_config_manager', function() { }); }); - describe('when constructed with sdkKey and with a valid datafile', function() { - it('does not call onUpdate listeners after becoming ready', function() { + describe('when constructed with sdkKey and with a valid datafile object', function() { + it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() { datafileManager.DatafileManager.returns({ start: sinon.stub(), stop: sinon.stub(), @@ -334,7 +370,37 @@ describe('lib/core/project_config/project_config_manager', function() { }); var onUpdateSpy = sinon.spy(); manager.onUpdate(onUpdateSpy); - return manager.onReady().then(function() { + return manager.onReady().then(function(result) { + assert.include(result, { + success: true, + }); + // Datafile is the same as what it was constructed with, so should + // not have called update listener + sinon.assert.notCalled(onUpdateSpy); + }); + }); + }); + + describe('when constructed with sdkKey and with a valid datafile string', function() { + it('fulfills its onReady promise with a successful result, and does not call onUpdate listeners after becoming ready', function() { + datafileManager.DatafileManager.returns({ + start: sinon.stub(), + stop: sinon.stub(), + get: sinon.stub().returns(testData.getTestProjectConfigWithFeatures()), + on: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve()) + }); + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: JSON.stringify(configWithFeatures), + sdkKey: '12345', + }); + var onUpdateSpy = sinon.spy(); + manager.onUpdate(onUpdateSpy); + return manager.onReady().then(function(result) { + assert.include(result, { + success: true, + }); // Datafile is the same as what it was constructed with, so should // not have called update listener sinon.assert.notCalled(onUpdateSpy); diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index 8c2ef2d78..fa944a88e 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -859,7 +859,7 @@ Optimizely.prototype.close = function() { }; /** - * Returns a Promise that resolves when this instance is ready to use (meaning + * Returns a Promise that fulfills when this instance is ready to use (meaning * it has a valid datafile), or has failed to become ready within a period of * time (configurable by the timeout property of the options argument). If a * valid datafile was provided in the constructor, the instance is immediately @@ -867,6 +867,16 @@ Optimizely.prototype.close = function() { * and the returned promise will resolve if that fetch succeeds or fails before * the timeout. The default timeout is 30 seconds, which will be used if no * timeout is provided in the argument options object. + * The returned Promise is fulfilled with a result object containing these + * properties: + * - success (boolean): True if this instance is ready to use with a valid + * datafile, or false if this instance failed to become + * ready. + * - reason (string=): If success is false, this is a string property with + * an explanatory message. Failure could be due to + * expiration of the timeout, network errors, + * unsuccessful responses, datafile parse errors, or + * datafile validation errors. * @param {Object=} options * @param {number|undefined} options.timeout * @return {Promise} @@ -881,7 +891,10 @@ Optimizely.prototype.onReady = function(options) { } var timeoutPromise = new Promise(function(resolve) { setTimeout(function() { - resolve(); + resolve({ + success: false, + reason: sprintf('onReady timeout expired after %s ms', timeout), + }); }, timeout); }); return Promise.race([this.__readyPromise, timeoutPromise]); diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index b2446cff1..e9a2efa61 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -4803,7 +4803,7 @@ describe('lib/optimizely', function() { clock.restore(); }); - it('fulfills the promise after the timeout has expired when the project config manager onReady promise still has not resolved', function() { + it('fulfills the promise with an unsuccessful result after the timeout has expired when the project config manager onReady promise still has not resolved', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, @@ -4815,10 +4815,14 @@ describe('lib/optimizely', function() { }); var readyPromise = optlyInstance.onReady({ timeout: 500 }); clock.tick(501); - return readyPromise; + return readyPromise.then(function(result) { + assert.include(result, { + success: false, + }); + }); }); - it('fulfills the promise after 30 seconds when no timeout argument is provided and the project config manager onReady promise still has not resolved', function() { + it('fulfills the promise with an unsuccessful result after 30 seconds when no timeout argument is provided and the project config manager onReady promise still has not resolved', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', errorHandler: errorHandler, @@ -4830,7 +4834,11 @@ describe('lib/optimizely', function() { }); var readyPromise = optlyInstance.onReady(); clock.tick(300001); - return readyPromise; + return readyPromise.then(function(result) { + assert.include(result, { + success: false, + }); + }); }); }); @@ -4856,7 +4864,6 @@ describe('lib/optimizely', function() { }); }); - // it('updates its project config object when the project config manager emits a new project config object', function() { it('uses the newest project config object from project config manager', function() { // Should start off returning false/null - no project config available assert.isFalse(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user45678')); From c4531e6f0c8c26a054f841a9dbacc5631a4b7c75 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 18 Apr 2019 14:45:41 -0700 Subject: [PATCH 28/42] fix (forced variations): Move forced variation methods and state out of project config and into decision service (#24) Summary: This is a solution for persisting forced variation state when the project config is updated from datafile management. Just as in Java, we moved the forced variation map and forced variation methods into decision service. Test plan: - Existing unit tests are passing with no substantial changes: the changes I made in existing unit tests only update log message assertions to reflect the new location of forced variation methods (previously in project config, now in decision service). - I moved forced variation method tests to the decision service tests file, and updated the method signatures to remove the logger argument (decision service already has a reference to its own logger). - I added a few more unit tests covering cases where a previously set variation or experiment no longer exists on a new project config object. --- .../lib/core/decision_service/index.js | 144 ++++++++++++- .../lib/core/decision_service/index.tests.js | 201 +++++++++++++++++- .../lib/core/project_config/index.js | 145 ------------- .../lib/core/project_config/index.tests.js | 171 --------------- .../optimizely-sdk/lib/optimizely/index.js | 4 +- .../lib/optimizely/index.tests.js | 36 ++-- 6 files changed, 353 insertions(+), 348 deletions(-) diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.js b/packages/optimizely-sdk/lib/core/decision_service/index.js index 7864d6abc..45b5e2c4e 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.js @@ -19,6 +19,7 @@ var bucketer = require('../bucketer'); var enums = require('../../utils/enums'); var fns = require('../../utils/fns'); var projectConfig = require('../project_config'); +var stringValidator = require('../../utils/string_value_validator'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; @@ -50,6 +51,7 @@ var DECISION_SOURCES = enums.DECISION_SOURCES; function DecisionService(options) { this.userProfileService = options.userProfileService || null; this.logger = options.logger; + this.forcedVariationMap = {}; } /** @@ -68,7 +70,7 @@ DecisionService.prototype.getVariation = function(configObj, experimentKey, user return null; } var experiment = configObj.experimentKeyMap[experimentKey]; - var forcedVariationKey = projectConfig.getForcedVariation(configObj, experimentKey, userId, this.logger); + var forcedVariationKey = this.getForcedVariation(configObj, experimentKey, userId); if (!!forcedVariationKey) { return forcedVariationKey; } @@ -468,6 +470,146 @@ DecisionService.prototype._getBucketingId = function(userId, attributes) { return bucketingId; }; +/** + * Removes forced variation for given userId and experimentKey + * @param {string} userId String representing the user id + * @param {number} experimentId Number representing the experiment id + * @param {string} experimentKey Key representing the experiment id + * @throws If the user id is not valid or not in the forced variation map + */ +DecisionService.prototype.removeForcedVariation = function(userId, experimentId, experimentKey) { + if (!userId) { + throw new Error(sprintf(ERROR_MESSAGES.INVALID_USER_ID, MODULE_NAME)); + } + + if (this.forcedVariationMap.hasOwnProperty(userId)) { + delete this.forcedVariationMap[userId][experimentId]; + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.VARIATION_REMOVED_FOR_USER, MODULE_NAME, experimentKey, userId)); + } else { + throw new Error(sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, MODULE_NAME, userId)); + } +}; + +/** + * Sets forced variation for given userId and experimentKey + * @param {string} userId String representing the user id + * @param {number} experimentId Number representing the experiment id + * @param {number} variationId Number representing the variation id + * @throws If the user id is not valid + */ +DecisionService.prototype.__setInForcedVariationMap = function(userId, experimentId, variationId) { + if (this.forcedVariationMap.hasOwnProperty(userId)) { + this.forcedVariationMap[userId][experimentId] = variationId; + } else { + this.forcedVariationMap[userId] = {}; + this.forcedVariationMap[userId][experimentId] = variationId; + } + + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, MODULE_NAME, variationId, experimentId, userId)); +}; + +/** + * Gets the forced variation key for the given user and experiment. + * @param {Object} configObj Object representing project configuration + * @param {string} experimentKey Key for experiment. + * @param {string} userId The user Id. + * @return {string|null} Variation The variation which the given user and experiment should be forced into. + */ +DecisionService.prototype.getForcedVariation = function(configObj, experimentKey, userId) { + var experimentToVariationMap = this.forcedVariationMap[userId]; + if (!experimentToVariationMap) { + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, MODULE_NAME, userId)); + return null; + } + + var experimentId; + try { + var experiment = projectConfig.getExperimentFromKey(configObj, experimentKey); + if (experiment.hasOwnProperty('id')) { + experimentId = experiment['id']; + } else { + // catching improperly formatted experiments + this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.IMPROPERLY_FORMATTED_EXPERIMENT, MODULE_NAME, experimentKey)); + return null; + } + } catch (ex) { + // catching experiment not in datafile + this.logger.log(LOG_LEVEL.ERROR, ex.message); + return null; + } + + var variationId = experimentToVariationMap[experimentId]; + if (!variationId) { + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT, MODULE_NAME, experimentKey, userId)); + return null; + } + + var variationKey = projectConfig.getVariationKeyFromId(configObj, variationId); + if (variationKey) { + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_FORCED_VARIATION, MODULE_NAME, variationKey, experimentKey, userId)); + } else { + this.logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT, MODULE_NAME, experimentKey, userId)); + } + + return variationKey; +}; + +/** + * Sets the forced variation for a user in a given experiment + * @param {Object} configObj Object representing project configuration + * @param {string} experimentKey Key for experiment. + * @param {string} userId The user Id. + * @param {string} variationKey Key for variation. If null, then clear the existing experiment-to-variation mapping + * @return {boolean} A boolean value that indicates if the set completed successfully. + */ +DecisionService.prototype.setForcedVariation = function(configObj, experimentKey, userId, variationKey) { + if (variationKey != null && !stringValidator.validate(variationKey)) { + this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.INVALID_VARIATION_KEY, MODULE_NAME)); + return false; + } + + var experimentId; + try { + var experiment = projectConfig.getExperimentFromKey(configObj, experimentKey); + if (experiment.hasOwnProperty('id')) { + experimentId = experiment['id']; + } else { + // catching improperly formatted experiments + this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.IMPROPERLY_FORMATTED_EXPERIMENT, MODULE_NAME, experimentKey)); + return false; + } + } catch (ex) { + // catching experiment not in datafile + this.logger.log(LOG_LEVEL.ERROR, ex.message); + return false; + } + + if (variationKey == null) { + try { + this.removeForcedVariation(userId, experimentId, experimentKey, this.logger); + return true; + } catch (ex) { + this.logger.log(LOG_LEVEL.ERROR, ex.message); + return false; + } + } + + var variationId = projectConfig.getVariationIdFromExperimentAndVariationKey(configObj, experimentKey, variationKey); + + if (!variationId) { + this.logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.NO_VARIATION_FOR_EXPERIMENT_KEY, MODULE_NAME, variationKey, experimentKey)); + return false; + } + + try { + this.__setInForcedVariationMap(userId, experimentId, variationId); + return true; + } catch (ex) { + this.logger.log(LOG_LEVEL.ERROR, ex.message); + return false; + } +}; + module.exports = { /** * Creates an instance of the DecisionService. diff --git a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js index ae4b78e70..5e46493e1 100644 --- a/packages/optimizely-sdk/lib/core/decision_service/index.tests.js +++ b/packages/optimizely-sdk/lib/core/decision_service/index.tests.js @@ -21,6 +21,7 @@ var errorHandler = require('../../plugins/error_handler'); var bucketer = require('../bucketer'); var DecisionService = require('./'); var enums = require('../../utils/enums'); +var fns = require('../../utils/fns'); var logger = require('../../plugins/logger'); var projectConfig = require('../project_config'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; @@ -68,14 +69,14 @@ describe('lib/core/decision_service', function() { assert.strictEqual('variationWithAudience', decisionServiceInstance.getVariation(configObj, 'testExperimentWithAudiences', 'user2')); sinon.assert.notCalled(bucketerStub); assert.strictEqual(2, mockLogger.log.callCount); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User user2 is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User user2 is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: User user2 is forced in variation variationWithAudience.'); }); it('should return null if the user does not meet audience conditions', function () { assert.isNull(decisionServiceInstance.getVariation(configObj, 'testExperimentWithAudiences', 'user3', {foo: 'bar'})); assert.strictEqual(7, mockLogger.log.callCount); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User user3 is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User user3 is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Evaluating audiences for experiment "testExperimentWithAudiences": ["11154"].'); assert.strictEqual(mockLogger.log.args[5][1], 'DECISION_SERVICE: Audiences for experiment testExperimentWithAudiences collectively evaluated to FALSE.'); assert.strictEqual(mockLogger.log.args[6][1], 'DECISION_SERVICE: User user3 does not meet conditions to be in experiment testExperimentWithAudiences.'); @@ -144,7 +145,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('control', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user')); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.notCalled(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"control\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.'); }); @@ -201,7 +202,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('control', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user')); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.calledOnce(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: User decision_service_user was previously bucketed into variation with ID not valid variation for experiment testExperiment, but no matching variation was found.'); // make sure we save the decision sinon.assert.calledWith(userProfileSaveStub, { @@ -233,7 +234,7 @@ describe('lib/core/decision_service', function() { }, }, }); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[3][1], 'DECISION_SERVICE: Saved variation "control" of experiment "testExperiment" for user "decision_service_user".'); }); @@ -244,7 +245,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('control', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user')); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Error while looking up user profile for user ID "decision_service_user": I am an error.'); }); @@ -258,7 +259,7 @@ describe('lib/core/decision_service', function() { sinon.assert.calledOnce(bucketerStub); // should still go through with bucketing assert.strictEqual(4, mockLogger.log.callCount); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[3][1], 'DECISION_SERVICE: Error while saving user profile for user ID "decision_service_user": I am an error.'); // make sure that we save the decision @@ -295,7 +296,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('variation', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user', attributes)); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.notCalled(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.'); }); @@ -320,7 +321,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('control', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user', attributes)); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.notCalled(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"control\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.'); }); @@ -345,7 +346,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('variation', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user', attributes)); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.notCalled(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.'); }); @@ -363,7 +364,7 @@ describe('lib/core/decision_service', function() { assert.strictEqual('variation', decisionServiceInstance.getVariation(configObj, 'testExperiment', 'decision_service_user', attributes)); sinon.assert.calledWith(userProfileLookupStub, 'decision_service_user'); sinon.assert.notCalled(bucketerStub); - assert.strictEqual(mockLogger.log.args[0][1], 'PROJECT_CONFIG: User decision_service_user is not in the forced variation map.'); + assert.strictEqual(mockLogger.log.args[0][1], 'DECISION_SERVICE: User decision_service_user is not in the forced variation map.'); assert.strictEqual(mockLogger.log.args[1][1], 'DECISION_SERVICE: Returning previously activated variation \"variation\" of experiment \"testExperiment\" for user \"decision_service_user\" from user profile.'); }); }); @@ -475,6 +476,184 @@ describe('lib/core/decision_service', function() { assert.isNull(decisionServiceInstance.__getWhitelistedVariation(testExperiment, 'notInForcedVariations')); }); }); + + describe('getForcedVariation', function() { + it('should return null for valid experimentKey, not set', function() { + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + assert.strictEqual(variation, null); + }); + + it('should return null for invalid experimentKey, not set', function() { + var variation = decisionServiceInstance.getForcedVariation(configObj, 'definitely_not_valid_exp_key', 'user1'); + assert.strictEqual(variation, null); + }); + + it('should return null for invalid experimentKey when a variation was previously successfully forced on another experiment for the same user', function() { + decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + var variation = decisionServiceInstance.getForcedVariation(configObj, 'definitely_not_valid_exp_key', 'user1'); + assert.strictEqual(variation, null); + }); + + it('should return null for valid experiment key, not set on this experiment key, but set on another experiment key', function() { + decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + assert.strictEqual(variation, null); + }); + }); + + describe('#setForcedVariation', function() { + it('should return true for a valid forcedVariation in setForcedVariation', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + }); + + it('should return the same variation from getVariation as was set in setVariation', function() { + decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + assert.strictEqual(variation, 'control'); + }); + + it('should not set for an invalid variation key', function() { + decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'definitely_not_valid_variation_key'); + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + assert.strictEqual(variation, null); + }); + + it('should reset the forcedVariation if passed null', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + assert.strictEqual(variation, 'control'); + + var didSetVariationAgain = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', null); + assert.strictEqual(didSetVariationAgain, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + assert.strictEqual(variation, null); + }); + + it('should be able to add variations for multiple experiments for one user', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + + var didSetVariation2 = decisionServiceInstance.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched'); + assert.strictEqual(didSetVariation2, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + + assert.strictEqual(variation, 'control'); + assert.strictEqual(variation2, 'controlLaunched'); + }); + + it('should be able to add experiments for multiple users', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user2', 'variation'); + assert.strictEqual(didSetVariation, true); + + var variationControl = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variationVariation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user2'); + + assert.strictEqual(variationControl, 'control'); + assert.strictEqual(variationVariation, 'variation'); + }); + + it('should be able to reset a variation for a user with multiple experiments', function() { + //set the first time + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + + var didSetVariation2 = decisionServiceInstance.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched'); + assert.strictEqual(didSetVariation2, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + + assert.strictEqual(variation, 'control'); + assert.strictEqual(variation2, 'controlLaunched'); + + //reset for one of the experiments + var didSetVariationAgain = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'variation'); + assert.strictEqual(didSetVariationAgain, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + + assert.strictEqual(variation, 'variation'); + assert.strictEqual(variation2, 'controlLaunched'); + }); + + it('should be able to unset a variation for a user with multiple experiments', function() { + //set the first time + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + + var didSetVariation2 = decisionServiceInstance.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched'); + assert.strictEqual(didSetVariation2, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + + assert.strictEqual(variation, 'control'); + assert.strictEqual(variation2, 'controlLaunched'); + + //reset for one of the experiments + decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', null); + assert.strictEqual(didSetVariation, true); + + var variation = decisionServiceInstance.getForcedVariation(configObj, 'testExperiment', 'user1'); + var variation2 = decisionServiceInstance.getForcedVariation(configObj, 'testExperimentLaunched', 'user1'); + + assert.strictEqual(variation, null); + assert.strictEqual(variation2, 'controlLaunched'); + }); + + it('should return false for an empty variation key', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', ''); + assert.strictEqual(didSetVariation, false); + }); + + it('should return null when a variation was previously set, and that variation no longer exists on the config object', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + var newDatafile = fns.cloneDeep(testData); + // Remove 'control' variation from variations, traffic allocation, and datafile forcedVariations. + newDatafile.experiments[0].variations = [{ + key: 'variation', + id: '111129', + }]; + newDatafile.experiments[0].trafficAllocation = [{ + entityId: '111129', + endOfRange: 9000, + }]; + newDatafile.experiments[0].forcedVariations = { + user1: 'variation', + user2: 'variation', + }; + // Now the only variation in testExperiment is 'variation' + var newConfigObj = projectConfig.createProjectConfig(newDatafile); + var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1'); + assert.strictEqual(forcedVar, null); + }); + + it('should return null when a variation was previously set, and that variation\'s experiment no longer exists on the config object', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'testExperiment', 'user1', 'control'); + assert.strictEqual(didSetVariation, true); + var newConfigObj = projectConfig.createProjectConfig(testDataWithFeatures); + var forcedVar = decisionServiceInstance.getForcedVariation(newConfigObj, 'testExperiment', 'user1'); + assert.strictEqual(forcedVar, null); + }); + + it('should return false from setForcedVariation and not set for invalid experiment key', function() { + var didSetVariation = decisionServiceInstance.setForcedVariation(configObj, 'definitelyNotAValidExperimentKey', 'user1', 'definitely_not_valid_variation_key'); + assert.strictEqual(didSetVariation, false); + var variation = decisionServiceInstance.getForcedVariation(configObj, 'definitelyNotAValidExperimentKey', 'user1'); + assert.strictEqual(variation, null); + }); + }); }); // TODO: Move tests that test methods of Optimizely to lib/optimizely/index.tests.js diff --git a/packages/optimizely-sdk/lib/core/project_config/index.js b/packages/optimizely-sdk/lib/core/project_config/index.js index 4d7f6274b..695e658a6 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.js @@ -16,7 +16,6 @@ var fns = require('../../utils/fns'); var enums = require('../../utils/enums'); var sprintf = require('@optimizely/js-sdk-utils').sprintf; -var stringValidator = require('../../utils/string_value_validator'); var configValidator = require('../../utils/config_validator'); var projectConfigSchema = require('./project_config_schema'); @@ -89,8 +88,6 @@ module.exports = { }); }); - projectConfig.forcedVariationMap = {}; - // Object containing experiment Ids that exist in any feature // for checking that experiment is a feature experiment or not. projectConfig.experimentFeatureMap = {}; @@ -298,148 +295,6 @@ module.exports = { return experiment.trafficAllocation; }, - /** - * Removes forced variation for given userId and experimentKey - * @param {Object} projectConfig Object representing project configuration - * @param {string} userId String representing the user id - * @param {number} experimentId Number representing the experiment id - * @param {string} experimentKey Key representing the experiment id - * @param {Object} logger - * @throws If the user id is not valid or not in the forced variation map - */ - removeForcedVariation: function(projectConfig, userId, experimentId, experimentKey, logger) { - if (!userId) { - throw new Error(sprintf(ERROR_MESSAGES.INVALID_USER_ID, MODULE_NAME)); - } - - if (projectConfig.forcedVariationMap.hasOwnProperty(userId)) { - delete projectConfig.forcedVariationMap[userId][experimentId]; - logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.VARIATION_REMOVED_FOR_USER, MODULE_NAME, experimentKey, userId)); - } else { - throw new Error(sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, MODULE_NAME, userId)); - } - }, - - /** - * Sets forced variation for given userId and experimentKey - * @param {Object} projectConfig Object representing project configuration - * @param {string} userId String representing the user id - * @param {number} experimentId Number representing the experiment id - * @param {number} variationId Number representing the variation id - * @param {Object} logger - * @throws If the user id is not valid - */ - setInForcedVariationMap: function(projectConfig, userId, experimentId, variationId, logger) { - if (projectConfig.forcedVariationMap.hasOwnProperty(userId)) { - projectConfig.forcedVariationMap[userId][experimentId] = variationId; - } else { - projectConfig.forcedVariationMap[userId] = {}; - projectConfig.forcedVariationMap[userId][experimentId] = variationId; - } - - logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, MODULE_NAME, variationId, experimentId, userId)); - }, - - /** - * Gets the forced variation key for the given user and experiment. - * @param {Object} projectConfig Object representing project configuration - * @param {string} experimentKey Key for experiment. - * @param {string} userId The user Id. - * @param {Object} logger - * @return {string|null} Variation The variation which the given user and experiment should be forced into. - */ - getForcedVariation: function(projectConfig, experimentKey, userId, logger) { - var experimentToVariationMap = projectConfig.forcedVariationMap[userId]; - if (!experimentToVariationMap) { - logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, MODULE_NAME, userId)); - return null; - } - - var experimentId; - try { - var experiment = this.getExperimentFromKey(projectConfig, experimentKey); - if (experiment.hasOwnProperty('id')) { - experimentId = experiment['id']; - } else { - // catching improperly formatted experiments - logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.IMPROPERLY_FORMATTED_EXPERIMENT, MODULE_NAME, experimentKey)); - return null; - } - } catch (ex) { - // catching experiment not in datafile - logger.log(LOG_LEVEL.ERROR, ex.message); - return null; - } - - var variationId = experimentToVariationMap[experimentId]; - if (!variationId) { - logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT, MODULE_NAME, experimentKey, userId)); - return null; - } - - var variationKey = this.getVariationKeyFromId(projectConfig, variationId); - logger.log(LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_FORCED_VARIATION, MODULE_NAME, variationKey, experimentKey, userId)); - - return variationKey; - }, - - /** - * Sets the forced variation for a user in a given experiment - * @param {Object} projectConfig Object representing project configuration - * @param {string} experimentKey Key for experiment. - * @param {string} userId The user Id. - * @param {string} variationKey Key for variation. If null, then clear the existing experiment-to-variation mapping - * @param {Object} logger - * @return {boolean} A boolean value that indicates if the set completed successfully. - */ - setForcedVariation: function(projectConfig, experimentKey, userId, variationKey, logger) { - if (variationKey != null && !stringValidator.validate(variationKey)) { - logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.INVALID_VARIATION_KEY, MODULE_NAME)); - return false; - } - - var experimentId; - try { - var experiment = this.getExperimentFromKey(projectConfig, experimentKey); - if (experiment.hasOwnProperty('id')) { - experimentId = experiment['id']; - } else { - // catching improperly formatted experiments - logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.IMPROPERLY_FORMATTED_EXPERIMENT, MODULE_NAME, experimentKey)); - return false; - } - } catch (ex) { - // catching experiment not in datafile - logger.log(LOG_LEVEL.ERROR, ex.message); - return false; - } - - if (variationKey == null) { - try { - this.removeForcedVariation(projectConfig, userId, experimentId, experimentKey, logger); - return true; - } catch (ex) { - logger.log(LOG_LEVEL.ERROR, ex.message); - return false; - } - } - - var variationId = this.getVariationIdFromExperimentAndVariationKey(projectConfig, experimentKey, variationKey); - - if (!variationId) { - logger.log(LOG_LEVEL.ERROR, sprintf(ERROR_MESSAGES.NO_VARIATION_FOR_EXPERIMENT_KEY, MODULE_NAME, variationKey, experimentKey)); - return false; - } - - try { - this.setInForcedVariationMap(projectConfig, userId, experimentId, variationId, logger); - return true; - } catch (ex) { - logger.log(LOG_LEVEL.ERROR, ex.message); - return false; - } - }, - /** * Get experiment from provided experiment id. Log an error if no experiment * exists in the project config with the given ID. diff --git a/packages/optimizely-sdk/lib/core/project_config/index.tests.js b/packages/optimizely-sdk/lib/core/project_config/index.tests.js index aadf25493..6e6e3b23e 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -561,177 +561,6 @@ describe('lib/core/project_config', function() { }); }); - describe('#getForcedVariation', function() { - var createdLogger = loggerPlugin.createLogger({ - logLevel: LOG_LEVEL.INFO, - logToConsole: false, - }); - - it('should return null for valid experimentKey, not set', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - assert.strictEqual(variation, null); - }); - - it('should return null for invalid experimentKey, not set', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var variation = projectConfig.getForcedVariation(configObj, 'definitely_not_valid_exp_key', 'user1', createdLogger); - assert.strictEqual(variation, null); - }); - }); - - describe('#setForcedVariation', function() { - var createdLogger = loggerPlugin.createLogger({ - logLevel: LOG_LEVEL.INFO, - logToConsole: false, - }); - - it('should return true for a valid forcedVariation in setForcedVariation', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - }); - - it('should return the same variation from getVariation as was set in setVariation', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - assert.strictEqual(variation, 'control'); - }); - - it('should not set for an invalid variation key', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'definitely_not_valid_variation_key', createdLogger); - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - assert.strictEqual(variation, null); - }); - - it('should reset the forcedVariation if passed null', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - assert.strictEqual(variation, 'control'); - - var didSetVariationAgain = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', null, createdLogger); - assert.strictEqual(didSetVariationAgain, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - assert.strictEqual(variation, null); - }); - - it('should be able to add variations for multiple experiments for one user', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - - var didSetVariation2 = projectConfig.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched', createdLogger); - assert.strictEqual(didSetVariation2, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variation2 = projectConfig.getForcedVariation(configObj, 'testExperimentLaunched', 'user1', createdLogger); - - assert.strictEqual(variation, 'control'); - assert.strictEqual(variation2, 'controlLaunched'); - }); - - it('should be able to add experiments for multiple users', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user2', 'variation', createdLogger); - assert.strictEqual(didSetVariation, true); - - var variationControl = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variationVariation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user2', createdLogger); - - assert.strictEqual(variationControl, 'control'); - assert.strictEqual(variationVariation, 'variation'); - }); - - it('should be able to reset a variation for a user with multiple experiments', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - //set the first time - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - - var didSetVariation2 = projectConfig.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched', createdLogger); - assert.strictEqual(didSetVariation2, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variation2 = projectConfig.getForcedVariation(configObj, 'testExperimentLaunched', 'user1', createdLogger); - - assert.strictEqual(variation, 'control'); - assert.strictEqual(variation2, 'controlLaunched'); - - //reset for one of the experiments - var didSetVariationAgain = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'variation', createdLogger); - assert.strictEqual(didSetVariationAgain, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variation2 = projectConfig.getForcedVariation(configObj, 'testExperimentLaunched', 'user1', createdLogger); - - assert.strictEqual(variation, 'variation'); - assert.strictEqual(variation2, 'controlLaunched'); - }); - - it('should be able to unset a variation for a user with multiple experiments', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - //set the first time - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', 'control', createdLogger); - assert.strictEqual(didSetVariation, true); - - var didSetVariation2 = projectConfig.setForcedVariation(configObj, 'testExperimentLaunched', 'user1', 'controlLaunched', createdLogger); - assert.strictEqual(didSetVariation2, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variation2 = projectConfig.getForcedVariation(configObj, 'testExperimentLaunched', 'user1', createdLogger); - - assert.strictEqual(variation, 'control'); - assert.strictEqual(variation2, 'controlLaunched'); - - //reset for one of the experiments - projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', null, createdLogger); - assert.strictEqual(didSetVariation, true); - - var variation = projectConfig.getForcedVariation(configObj, 'testExperiment', 'user1', createdLogger); - var variation2 = projectConfig.getForcedVariation(configObj, 'testExperimentLaunched', 'user1', createdLogger); - - assert.strictEqual(variation, null); - assert.strictEqual(variation2, 'controlLaunched'); - }); - - it('should return false for an empty variation key', function() { - var testData = testDatafile.getTestProjectConfig(); - var configObj = projectConfig.createProjectConfig(testData); - - var didSetVariation = projectConfig.setForcedVariation(configObj, 'testExperiment', 'user1', '', createdLogger); - assert.strictEqual(didSetVariation, false); - }); - }); - describe('#tryCreatingProjectConfig', function() { var stubJsonSchemaValidator; beforeEach(function() { diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index c76ac087d..4e6d59597 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -424,7 +424,7 @@ Optimizely.prototype.setForcedVariation = function(experimentKey, userId, variat } try { - return projectConfig.setForcedVariation(configObj, experimentKey, userId, variationKey, this.logger); + return this.decisionService.setForcedVariation(configObj, experimentKey, userId, variationKey); } catch (ex) { this.logger.log(LOG_LEVEL.ERROR, ex.message); this.errorHandler.handleError(ex); @@ -449,7 +449,7 @@ Optimizely.prototype.getForcedVariation = function(experimentKey, userId) { } try { - return projectConfig.getForcedVariation(configObj, experimentKey, userId, this.logger); + return this.decisionService.getForcedVariation(configObj, experimentKey, userId); } catch (ex) { this.logger.log(LOG_LEVEL.ERROR, ex.message); this.errorHandler.handleError(ex); diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index b3253df26..41d5bba4d 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -589,7 +589,7 @@ describe('lib/optimizely', function() { sinon.assert.called(createdLogger.log); sinon.assert.calledWithExactly(createdLogger.log, LOG_LEVEL.DEBUG, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, - 'PROJECT_CONFIG', + 'DECISION_SERVICE', 'testUser')); @@ -605,7 +605,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWithExactly( createdLogger.log, LOG_LEVEL.DEBUG, - sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'testUser') + sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'testUser') ); sinon.assert.calledWithExactly( @@ -627,7 +627,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWithExactly( createdLogger.log, LOG_LEVEL.DEBUG, - sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'testUser') + sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'testUser') ); sinon.assert.calledWithExactly( @@ -732,7 +732,7 @@ describe('lib/optimizely', function() { sinon.assert.calledTwice(Optimizely.prototype.__validateInputs); var logMessage0 = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'user1')); + assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'user1')); var logMessage1 = createdLogger.log.args[1][1]; assert.strictEqual(logMessage1, sprintf(LOG_MESSAGES.USER_FORCED_IN_VARIATION, 'DECISION_SERVICE', 'user1', 'control')); @@ -1406,7 +1406,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWithExactly( createdLogger.log, LOG_LEVEL.DEBUG, - sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'testUser') + sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'testUser') ); }); @@ -1435,7 +1435,7 @@ describe('lib/optimizely', function() { sinon.assert.calledWithExactly( createdLogger.log, LOG_LEVEL.DEBUG, - sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'testUser') + sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'testUser') ); sinon.assert.calledWithExactly( @@ -1506,7 +1506,7 @@ describe('lib/optimizely', function() { sinon.assert.calledTwice(createdLogger.log); var logMessage0 = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'user1')); + assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'user1')); var logMessage = createdLogger.log.args[1][1]; assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.USER_FORCED_IN_VARIATION, 'DECISION_SERVICE', 'user1', 'control')); }); @@ -1565,7 +1565,7 @@ describe('lib/optimizely', function() { assert.strictEqual(forcedVariation, null); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'PROJECT_CONFIG', 'user1')); + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION, 'DECISION_SERVICE', 'user1')); }); it('should return null with a null experimentKey', function() { @@ -1607,7 +1607,7 @@ describe('lib/optimizely', function() { assert.strictEqual(didSetVariation, true); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'PROJECT_CONFIG', 111128, 111127, 'user1')); + assert.strictEqual(logMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'DECISION_SERVICE', 111128, 111127, 'user1')); }); it('should override bucketing in optlyInstance.getVariation', function() { @@ -1643,11 +1643,11 @@ describe('lib/optimizely', function() { var variationIsMappedLogMessage = createdLogger.log.args[1][1]; var variationMappingRemovedLogMessage = createdLogger.log.args[2][1]; - assert.strictEqual(setVariationLogMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'PROJECT_CONFIG', 111128, 111127, 'user1')); + assert.strictEqual(setVariationLogMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'DECISION_SERVICE', 111128, 111127, 'user1')); - assert.strictEqual(variationIsMappedLogMessage, sprintf(LOG_MESSAGES.USER_HAS_FORCED_VARIATION, 'PROJECT_CONFIG', 'control', 'testExperiment', 'user1')); + assert.strictEqual(variationIsMappedLogMessage, sprintf(LOG_MESSAGES.USER_HAS_FORCED_VARIATION, 'DECISION_SERVICE', 'control', 'testExperiment', 'user1')); - assert.strictEqual(variationMappingRemovedLogMessage, sprintf(LOG_MESSAGES.VARIATION_REMOVED_FOR_USER, 'PROJECT_CONFIG', 'testExperiment', 'user1')); + assert.strictEqual(variationMappingRemovedLogMessage, sprintf(LOG_MESSAGES.VARIATION_REMOVED_FOR_USER, 'DECISION_SERVICE', 'testExperiment', 'user1')); }); it('should be able to set multiple experiments for one user', function() { @@ -1669,7 +1669,7 @@ describe('lib/optimizely', function() { assert.strictEqual(didSetVariation, false); var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.NO_VARIATION_FOR_EXPERIMENT_KEY, 'PROJECT_CONFIG', 'definitely_not_valid_variation_key', 'testExperiment')); + assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.NO_VARIATION_FOR_EXPERIMENT_KEY, 'DECISION_SERVICE', 'definitely_not_valid_variation_key', 'testExperiment')); }); it('should not set an invalid experiment', function() { @@ -1688,10 +1688,10 @@ describe('lib/optimizely', function() { assert.strictEqual(forcedVariation, null); var setVariationLogMessage = createdLogger.log.args[0][1]; - assert.strictEqual(setVariationLogMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'PROJECT_CONFIG', 111128, 111127, 'user1')); + assert.strictEqual(setVariationLogMessage, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'DECISION_SERVICE', 111128, 111127, 'user1')); var noVariationToGetLogMessage = createdLogger.log.args[1][1]; - assert.strictEqual(noVariationToGetLogMessage, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT, 'PROJECT_CONFIG', 'testExperimentLaunched', 'user1')); + assert.strictEqual(noVariationToGetLogMessage, sprintf(LOG_MESSAGES.USER_HAS_NO_FORCED_VARIATION_FOR_EXPERIMENT, 'DECISION_SERVICE', 'testExperimentLaunched', 'user1')); }); it('should return false for a null experimentKey', function() { @@ -1744,7 +1744,7 @@ describe('lib/optimizely', function() { assert.strictEqual(didSetVariation, false); var setVariationLogMessage = createdLogger.log.args[0][1]; - assert.strictEqual(setVariationLogMessage, sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, 'PROJECT_CONFIG', 'user1')); + assert.strictEqual(setVariationLogMessage, sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, 'DECISION_SERVICE', 'user1')); }); it('should return false for an undefined variationKey', function() { @@ -1752,7 +1752,7 @@ describe('lib/optimizely', function() { assert.strictEqual(didSetVariation, false); var setVariationLogMessage = createdLogger.log.args[0][1]; - assert.strictEqual(setVariationLogMessage, sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, 'PROJECT_CONFIG', 'user1')); + assert.strictEqual(setVariationLogMessage, sprintf(ERROR_MESSAGES.USER_NOT_IN_FORCED_VARIATION, 'DECISION_SERVICE', 'user1')); }); it('should not override check for not running experiments in getVariation', function() { @@ -1763,7 +1763,7 @@ describe('lib/optimizely', function() { assert.strictEqual(variation, null); var logMessage0 = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'PROJECT_CONFIG', 133338, 133337, 'user1')); + assert.strictEqual(logMessage0, sprintf(LOG_MESSAGES.USER_MAPPED_TO_FORCED_VARIATION, 'DECISION_SERVICE', 133338, 133337, 'user1')); var logMessage1 = createdLogger.log.args[1][1]; assert.strictEqual(logMessage1, sprintf(LOG_MESSAGES.EXPERIMENT_NOT_RUNNING, 'DECISION_SERVICE', 'testExperimentNotRunning')); From de8546c1bb3df7174f0a6e27ef0de68dff09c287 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Fri, 19 Apr 2019 16:25:16 -0700 Subject: [PATCH 29/42] fix (onReady): Clear ready timeout when instance closed, and other small fixes (#25) Summary: Previously, if close was called with pending ready timeouts, those timeouts would remain active. With this change, we clear all pending ready timeouts when close is called. To guard against Promises returned from onReady remaining permanently pending, in close, we resolve any ready promises associated with pending timeouts with unsuccessful results. There are two other small changes in this PR: - Improve documentation comments for Optimzely onReady and ProjectConfigManager onReady - Return false from setForcedVariation when no configObj available (was previously returning null) Test plan: Updated unit tests, existing unit tests should continue passing --- .../project_config/project_config_manager.js | 19 ++++- .../optimizely-sdk/lib/optimizely/index.js | 68 +++++++++++++----- .../lib/optimizely/index.tests.js | 70 ++++++++++++++++++- 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js index ad78377db..2a6eddb2b 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -285,8 +285,23 @@ ProjectConfigManager.prototype.getConfig = function() { }; /** - * Returns a Promise that resolves when this ProjectConfigManager has a non-null - * project config object available for the first time. + * Returns a Promise that fulfills when this ProjectConfigManager is ready to + * use (meaning it has a valid project config object), or has failed to become + * ready. + * + * Failure can be caused by the following: + * - At least one of sdkKey or datafile is not provided in the constructor argument + * - The provided datafile was invalid + * - The datafile provided by the datafile manager was invalid + * - The datafile manager failed to fetch a datafile + * + * The returned Promise is fulfilled with a result object containing these + * properties: + * - success (boolean): True if this instance is ready to use with a valid + * project config object, or false if it failed to + * become ready + * - reason (string=): If success is false, this is a string property with + * an explanatory message. * @return {Promise} */ ProjectConfigManager.prototype.onReady = function() { diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index 4e6d59597..18927ccaa 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -113,6 +113,9 @@ function Optimizely(config) { maxQueueSize: config.eventBatchSize || DEFAULT_EVENT_MAX_QUEUE_SIZE, }); this.eventProcessor.start(); + + this.__readyTimeouts = {}; + this.__nextReadyTimeoutId = 0; } /** @@ -420,7 +423,7 @@ Optimizely.prototype.setForcedVariation = function(experimentKey, userId, variat var configObj = this.projectConfigManager.getConfig(); if (!configObj) { - return null; + return false; } try { @@ -582,7 +585,7 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) source: decision.decisionSource, sourceInfo: sourceInfo }; - + this.notificationCenter.sendNotifications( NOTIFICATION_TYPES.DECISION, { @@ -857,6 +860,12 @@ Optimizely.prototype.close = function() { if (this.projectConfigManager) { this.projectConfigManager.stop(); } + Object.keys(this.__readyTimeouts).forEach(function(readyTimeoutId) { + var readyTimeoutRecord = this.__readyTimeouts[readyTimeoutId]; + clearTimeout(readyTimeoutRecord.readyTimeout); + readyTimeoutRecord.onClose(); + }.bind(this)); + this.__readyTimeouts = {}; } catch (e) { this.logger.log(LOG_LEVEL.ERROR, e.message); this.errorHandler.handleError(e); @@ -866,22 +875,26 @@ Optimizely.prototype.close = function() { /** * Returns a Promise that fulfills when this instance is ready to use (meaning * it has a valid datafile), or has failed to become ready within a period of - * time (configurable by the timeout property of the options argument). If a - * valid datafile was provided in the constructor, the instance is immediately - * ready. If an sdkKey was provided, a manager will be used to fetch a datafile, - * and the returned promise will resolve if that fetch succeeds or fails before - * the timeout. The default timeout is 30 seconds, which will be used if no - * timeout is provided in the argument options object. + * time (configurable by the timeout property of the options argument), or when + * this instance is closed via the close method. + * + * If a valid datafile was provided in the constructor, the returned Promise is + * immediately fulfilled. If an sdkKey was provided, a manager will be used to + * fetch a datafile, and the returned promise will fulfill if that fetch + * succeeds or fails before the timeout. The default timeout is 30 seconds, + * which will be used if no timeout is provided in the argument options object. + * * The returned Promise is fulfilled with a result object containing these * properties: * - success (boolean): True if this instance is ready to use with a valid * datafile, or false if this instance failed to become - * ready. + * ready or was closed prior to becoming ready. * - reason (string=): If success is false, this is a string property with * an explanatory message. Failure could be due to * expiration of the timeout, network errors, - * unsuccessful responses, datafile parse errors, or - * datafile validation errors. + * unsuccessful responses, datafile parse errors, + * datafile validation errors, or the instance being + * closed * @param {Object=} options * @param {number|undefined} options.timeout * @return {Promise} @@ -894,14 +907,35 @@ Optimizely.prototype.onReady = function(options) { if (!fns.isFinite(timeout)) { timeout = DEFAULT_ONREADY_TIMEOUT; } + + var resolveTimeoutPromise; var timeoutPromise = new Promise(function(resolve) { - setTimeout(function() { - resolve({ - success: false, - reason: sprintf('onReady timeout expired after %s ms', timeout), - }); - }, timeout); + resolveTimeoutPromise = resolve; }); + + var timeoutId = this.__nextReadyTimeoutId; + this.__nextReadyTimeoutId++; + + var onReadyTimeout = function() { + delete this.__readyTimeouts[timeoutId]; + resolveTimeoutPromise({ + success: false, + reason: sprintf('onReady timeout expired after %s ms', timeout), + }); + }.bind(this); + var readyTimeout = setTimeout(onReadyTimeout, timeout); + var onClose = function() { + resolveTimeoutPromise({ + success: false, + reason: 'Instance closed', + }); + }; + + this.__readyTimeouts[timeoutId] = { + readyTimeout: readyTimeout, + onClose: onClose, + }; + return Promise.race([this.__readyPromise, timeoutPromise]); }; diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 41d5bba4d..c4acf4919 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.tests.js +++ b/packages/optimizely-sdk/lib/optimizely/index.tests.js @@ -4810,7 +4810,7 @@ describe('lib/optimizely', function() { it('returns fallback values from API methods that return meaningful values', function() { assert.isNull(optlyInstance.activate('my_experiment', 'user1')); assert.isNull(optlyInstance.getVariation('my_experiment', 'user1')); - assert.isNull(optlyInstance.setForcedVariation('my_experiment', 'user1', 'variation_1')); + assert.isFalse(optlyInstance.setForcedVariation('my_experiment', 'user1', 'variation_1')); assert.isNull(optlyInstance.getForcedVariation('my_experiment', 'user1')); assert.isFalse(optlyInstance.isFeatureEnabled('my_feature', 'user1')); assert.deepEqual(optlyInstance.getEnabledFeatures('user1'), []); @@ -4839,6 +4839,30 @@ describe('lib/optimizely', function() { clock.restore(); }); + it('fulfills the promise with the value from the project config manager ready promise after the project config manager ready promise is fulfilled', function() { + projectConfigManager.ProjectConfigManager.callsFake(function(config) { + var currentConfig = config.datafile ? projectConfig.createProjectConfig(config.datafile) : null; + return { + stop: sinon.stub(), + getConfig: sinon.stub().returns(currentConfig), + onUpdate: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns(Promise.resolve({ success: true })), + }; + }); + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + return optlyInstance.onReady().then(function(result) { + assert.deepEqual(result, { success: true }); + }); + }); + it('fulfills the promise with an unsuccessful result after the timeout has expired when the project config manager onReady promise still has not resolved', function() { optlyInstance = new Optimizely({ clientEngine: 'node-sdk', @@ -4876,6 +4900,50 @@ describe('lib/optimizely', function() { }); }); }); + + it('fulfills the promise with an unsuccessful result after the instance is closed', function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + var readyPromise = optlyInstance.onReady({ timeout: 100 }); + optlyInstance.close(); + return readyPromise.then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + it('can be called several times with different timeout values and the returned promises behave correctly', function() { + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + var readyPromise1 = optlyInstance.onReady({ timeout: 100 }); + var readyPromise2 = optlyInstance.onReady({ timeout: 200 }); + var readyPromise3 = optlyInstance.onReady({ timeout: 300 }); + clock.tick(101); + return readyPromise1.then(function() { + clock.tick(100); + return readyPromise2; + }).then(function() { + // readyPromise3 has not resolved yet because only 201 ms have elapsed. + // Calling close on the instance should resolve readyPromise3 + optlyInstance.close(); + return readyPromise3; + }); + }); }); describe('project config updates', function() { From 0f55be2ee5d102cbd20724e31be6f79ee348a915 Mon Sep 17 00:00:00 2001 From: Michael Ng Date: Tue, 23 Apr 2019 09:17:12 -0700 Subject: [PATCH 30/42] ci(tests): Enable FSC tests to run on this repo. (#26) --- .travis.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.travis.yml b/.travis.yml index 832f0ed32..3672cc403 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,18 @@ addons: srcclr: true script: npm test after_success: npm run coveralls + +stages: + - 'Integration tests' + - 'Test' + +jobs: + include: + - stage: 'Integration tests' + env: SDK=javascript + cache: false + language: minimal + install: + - mkdir $HOME/travisci-tools && pushd $HOME/travisci-tools && git init && git pull https://$CI_USER_TOKEN@github.com/optimizely/travisci-tools.git && popd + script: + - "$HOME/travisci-tools/fsc-trigger/trigger_fullstack-sdk-compat.sh" From 3b4de0f762da2fd54eb69233d68277125d097f01 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 23 Apr 2019 16:18:46 -0700 Subject: [PATCH 31/42] Remove TODO comment --- packages/datafile-manager/__test__/browserRequest.spec.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/datafile-manager/__test__/browserRequest.spec.ts b/packages/datafile-manager/__test__/browserRequest.spec.ts index 9bcf156e3..46f6b3b26 100644 --- a/packages/datafile-manager/__test__/browserRequest.spec.ts +++ b/packages/datafile-manager/__test__/browserRequest.spec.ts @@ -1,10 +1,6 @@ /** * @jest-environment jsdom */ - -// TODO: It doesn't work unless the jest-enviroment comment is at the top... -// ...so if we need the license header at the top, we have to fix this - /** * Copyright 2019, Optimizely * From ceedcd42155936995bbabde104b6e471d204a6bc Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 23 Apr 2019 16:46:00 -0700 Subject: [PATCH 32/42] Update license headers on modified files (#27) Summary: There were a few files that were created/modified during development of datafile management that did not get updated license headers. This fixes those files. Test plan: None. Should be comment-only changes --- packages/datafile-manager/jest.config.js | 16 ++++++++++++++++ .../core/project_config/project_config_schema.js | 2 +- .../optimizely-sdk/lib/index.browser.tests.js | 2 +- .../optimizely-sdk/lib/index.browser.umdtests.js | 2 +- packages/optimizely-sdk/lib/index.node.tests.js | 2 +- .../utils/json_schema_validator/index.tests.js | 2 +- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/datafile-manager/jest.config.js b/packages/datafile-manager/jest.config.js index a260bda52..e1a14d3fc 100644 --- a/packages/datafile-manager/jest.config.js +++ b/packages/datafile-manager/jest.config.js @@ -1,3 +1,19 @@ +/** + * 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. + */ + // For a detailed explanation regarding each configuration property, visit: // https://jestjs.io/docs/en/configuration.html diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js index a2e96ef6a..7167045f3 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, Optimizely + * Copyright 2016-2019, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/index.browser.tests.js b/packages/optimizely-sdk/lib/index.browser.tests.js index 9862264ee..eba9ef1a7 100644 --- a/packages/optimizely-sdk/lib/index.browser.tests.js +++ b/packages/optimizely-sdk/lib/index.browser.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, Optimizely + * Copyright 2016-2019, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/index.browser.umdtests.js b/packages/optimizely-sdk/lib/index.browser.umdtests.js index 9738a0667..88ef1f394 100644 --- a/packages/optimizely-sdk/lib/index.browser.umdtests.js +++ b/packages/optimizely-sdk/lib/index.browser.umdtests.js @@ -1,5 +1,5 @@ /** - * Copyright 2018, Optimizely + * Copyright 2018-2019, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/index.node.tests.js b/packages/optimizely-sdk/lib/index.node.tests.js index b7187e9e5..5ccbe9541 100644 --- a/packages/optimizely-sdk/lib/index.node.tests.js +++ b/packages/optimizely-sdk/lib/index.node.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, Optimizely + * Copyright 2016-2019, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js index df1cbd88b..89d4f0ac6 100644 --- a/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js +++ b/packages/optimizely-sdk/lib/utils/json_schema_validator/index.tests.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2017, Optimizely + * Copyright 2016-2019, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From 37134f96a8ab0aca9bcce193a599546722ff466a Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 23 Apr 2019 15:57:18 -0700 Subject: [PATCH 33/42] Restore travis configuration from master --- .travis.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3672cc403..470aebed3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,16 @@ node_js: - '8' - '9' - '10' +branches: + only: + - master + - /^\d+\.\d+\.(\d|[x]) +env: + global: + # BROWSER_STACK_USERNAME + - secure: WnyM4gMsi2n69O/YZUD/pYwHJXdKDcBv3Hwft2cCw52yYc+z75uuRgdaLKs4BPisckBtnR17dH7hKlPX3HWwjCoqQm1q5qNpbJrArWaEcbotWGF2YFy21ZZ4rKNQmJqdgRj6XFZhLHbncA8v2gQPK7F6GUJ0vsJF/kiTfxAUjefR23oorcKSQrh9BfOxNAYu2Ma92qlaaMmHYbBdlNDM45/EQE+LnPCfboCiJD/5zTYq4Q+XhKLPV01vUDU60pj9ckDNXyLj9X2BwMbAzGAPGE4qTAB/IrMndVsUXblsahtwKQ6yrsUrsdTASz8/3oNkImtY7fqU874jSeG3d7PNBfZs47zkXEVy73ZWNBgM9rzVS5cPaIU3wqpuBoXFntDJcdHQhNTWEYdxmtcTUmxKt5TdUzDhrrkcti2WVLabU3N52aOBeOM0XBpfLkbV+HT6oWi3bNUb+EDMHvCxOxsP4IoEDfFs9HMzNIO3mmC3+2DFbI7s2Mb2oacAut38MbJDYSDTOLL4smG8scA3E0RQO4r8+TNk4aRIMQc7vCKqz7PpbO7Aj9dXSpeHrDmIszSmEoQqmaaGsRBwbXRom2P8fB9FcTbd/wbsfgoFNEPz5DlbtCtCmt0pQMa+3myWveKH52WC5KlFijBSDjYOMUnXbLnj5fK5eKaWp+z6/qcNwU8= + # BROWSER_STACK_ACCESS_KEY + - secure: U0GGZw46rJowBtH9gVluIrerB40u2b3uZpH0HsOdLlsXCCaTVk4JXX/JPVPashWAFLC7Enk3UOE4ofeEpVd0wbG6CxtG9/gklc2U2tvkqsdPpFZKaRrXoUzCyyPOmHEC2mXDXctbrncmttM4APaceRfbdTBEZIIfyLJadomjWylA61szFE9IZjvJpiwJO2xa5HI9GVRu3yXJci+riJux+JsDmfJ1hNwv3waMeeg/scddUH0hfgq69ftGs8cpMlYiO20eh32S7uPF7/IJTH1fDJjVKYQZwpypkF6AeI+od5CFTY1ajb25eaBNXThLS0Bo9ZJE/8Sogvon21dEJkt/ClY6R341InbAFXZvz7jyQAisvh0I4zxcu0VUCfh7bEUl6GXMO8VJnyxHEfqB+AIT2RoMXckkhulwiNUsJYH1yJ8mjnLvZq85mWBCp4n4jg0K6Wf46lHpjnHOVpLyLyoFGfiPf90AQVL02AJ3/ia8RkMuj0Ax+AGtiTC/+wy7dsDQOif/VpBNJcx/RciQ24mYOGzAMh4GsUWnXaZ9vXSxliogVNrmIefK5invJ0omv9pIx8NZHTHYGaulh4w6JsliiEq2kH78SlyvSrcsFGTwCY97LLaxiLm/75/Zf+F7LajKC23Fbtnj/LQizitFZqGMJ09DnR52krBAeultqRq8QLM= before_install: cd packages/optimizely-sdk install: npm install addons: @@ -11,17 +21,26 @@ addons: script: npm test after_success: npm run coveralls +# Integration tests need to run first to reset the PR build status to pending stages: - 'Integration tests' + - 'Cross-browser and umd unit tests' - 'Test' jobs: include: - stage: 'Integration tests' + merge_mode: replace env: SDK=javascript cache: false language: minimal - install: + before_install: skip + install: skip + before_script: - mkdir $HOME/travisci-tools && pushd $HOME/travisci-tools && git init && git pull https://$CI_USER_TOKEN@github.com/optimizely/travisci-tools.git && popd script: - "$HOME/travisci-tools/fsc-trigger/trigger_fullstack-sdk-compat.sh" + after_success: travis_terminate 0 + - stage: Cross-browser and umd unit tests + node_js: '8' + script: npm run test-ci From 431ac4956dce07183a655032838d16f73a37387a Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 10:05:56 -0700 Subject: [PATCH 34/42] Add datafile management entry in CHANGELOG.md --- packages/optimizely-sdk/CHANGELOG.MD | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index d9ecc0157..1724e8501 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] Changes that have landed but are not yet released. +### New Features + +- Added support for automatic datafile management + - To use automatic datafile management, include `sdkKey` as a string property in the options object you pass to `createInstance`. + - When sdkKey is provided, the SDK instance will download the datafile associated with that sdkKey immediately upon construction. When the download completes, the SDK instance will update itself to use the downloaded datafile. + - Use the `onReady` method to wait until the download is complete and the SDK is ready to use. + - Customize datafile management behavior by passing a `datafileOptions` object within the options you pass to `createInstance`. + - Enable automatic updates by passing `autoUpdate: true`. Periodically (on the provided update interval), the SDK instance will download the datafile and update itself. Use this to ensure that the SDK instance is using a fresh datafile reflecting changes recently made to your experiment or feature configuration. + - Stop active downloads and cancel pending downloads by calling the `close` method + + + #### Create an instance with datafile management enabled + ```js + const optimizely = require('@optimizely/optimizely-sdk'); + const instance = optimizely.createInstance({ + sdkKey: ‘12345’, // Provide the sdkKey of your desired environment here + }); + ``` + + #### Use `onReady` to wait until the instance has a datafile + ```js + const optimizely = require('@optimizely/optimizely-sdk'); + const instance = optimizely.createInstance({ + sdkKey: ‘12345’, + }); + instance.onReady().then(() => { + // instance is ready to use, with datafile downloaded from Optimizely CDN + }); + ``` + + #### Enable automatic updates, and stop automatic updates + ```js + const optimizely = require('@optimizely/optimizely-sdk'); + let instance = optimizely.createInstance({ + sdkKey: ‘12345’, + datafileOptions: { + autoUpdate: true, + updateInterval: 600000 // 10 minutes in milliseconds + }, + }); + // Stop automatic updates - instance will use whatever datafile it currently has from now on + instance.close(); + ``` + ## [3.1.0] - April 22nd, 2019 ### New Features: From e268ba5a44e53ec3e5fdaf2ff0343bb693da7b75 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 10:07:13 -0700 Subject: [PATCH 35/42] Fix code sample in CHANGELOG.md --- packages/optimizely-sdk/CHANGELOG.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index 1724e8501..91afe49e2 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -40,7 +40,7 @@ Changes that have landed but are not yet released. #### Enable automatic updates, and stop automatic updates ```js const optimizely = require('@optimizely/optimizely-sdk'); - let instance = optimizely.createInstance({ + const instance = optimizely.createInstance({ sdkKey: ‘12345’, datafileOptions: { autoUpdate: true, From cadfd6636bea3591cf8391d56c9dd5fbb548dcd4 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 11:10:39 -0700 Subject: [PATCH 36/42] Add config update notification to changelog entry --- packages/optimizely-sdk/CHANGELOG.MD | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index 91afe49e2..624c0f565 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -15,6 +15,7 @@ Changes that have landed but are not yet released. - Use the `onReady` method to wait until the download is complete and the SDK is ready to use. - Customize datafile management behavior by passing a `datafileOptions` object within the options you pass to `createInstance`. - Enable automatic updates by passing `autoUpdate: true`. Periodically (on the provided update interval), the SDK instance will download the datafile and update itself. Use this to ensure that the SDK instance is using a fresh datafile reflecting changes recently made to your experiment or feature configuration. + - Add a notification listener for the `OPTIMIZELY_CONFIG_UPDATE` notification type to be notified when an instance updates its Optimizely config after obtaining a new datafile. - Stop active downloads and cancel pending downloads by calling the `close` method @@ -37,7 +38,7 @@ Changes that have landed but are not yet released. }); ``` - #### Enable automatic updates, and stop automatic updates + #### Enable automatic updates, add notification listener for OPTIMIZELY_CONFIG_UPDATE notification type, and stop automatic updates ```js const optimizely = require('@optimizely/optimizely-sdk'); const instance = optimizely.createInstance({ @@ -47,6 +48,12 @@ Changes that have landed but are not yet released. updateInterval: 600000 // 10 minutes in milliseconds }, }); + instance.notificationCenter.addNotificationListener( + optimizely.enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, + () => { + // instance has updated its Optimizely config + }, + ); // Stop automatic updates - instance will use whatever datafile it currently has from now on instance.close(); ``` From 4a26bec66ad477889bdb239735f90db134e862d6 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 11:25:23 -0700 Subject: [PATCH 37/42] Use proper naming conventions in code samples --- packages/optimizely-sdk/CHANGELOG.MD | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index 624c0f565..6d6ed5abb 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -22,40 +22,40 @@ Changes that have landed but are not yet released. #### Create an instance with datafile management enabled ```js const optimizely = require('@optimizely/optimizely-sdk'); - const instance = optimizely.createInstance({ + const optimizelyClientInstance = optimizely.createInstance({ sdkKey: ‘12345’, // Provide the sdkKey of your desired environment here }); ``` - #### Use `onReady` to wait until the instance has a datafile + #### Use `onReady` to wait until optimizelyClientInstance has a datafile ```js const optimizely = require('@optimizely/optimizely-sdk'); - const instance = optimizely.createInstance({ + const optimizelyClientInstance = optimizely.createInstance({ sdkKey: ‘12345’, }); - instance.onReady().then(() => { - // instance is ready to use, with datafile downloaded from Optimizely CDN + optimizelyClientInstance.onReady().then(() => { + // optimizelyClientInstance is ready to use, with datafile downloaded from the Optimizely CDN }); ``` #### Enable automatic updates, add notification listener for OPTIMIZELY_CONFIG_UPDATE notification type, and stop automatic updates ```js const optimizely = require('@optimizely/optimizely-sdk'); - const instance = optimizely.createInstance({ + const optimizelyClientInstance = optimizely.createInstance({ sdkKey: ‘12345’, datafileOptions: { autoUpdate: true, updateInterval: 600000 // 10 minutes in milliseconds }, }); - instance.notificationCenter.addNotificationListener( + optimizelyClientInstance.notificationCenter.addNotificationListener( optimizely.enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, () => { - // instance has updated its Optimizely config + // optimizelyClientInstance has updated its Optimizely config }, ); - // Stop automatic updates - instance will use whatever datafile it currently has from now on - instance.close(); + // Stop automatic updates - optimizelyClientInstance will use whatever datafile it currently has from now on + optimizelyClientInstance.close(); ``` ## [3.1.0] - April 22nd, 2019 From ae1c70a9449fc77f4705f41ecb13dd116334c6ba Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 21:31:20 -0700 Subject: [PATCH 38/42] Change node engines value in pacakge.json from >=4.0.0 to >=6.0.0 --- packages/datafile-manager/CHANGELOG.md | 3 +++ packages/datafile-manager/package.json | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/datafile-manager/CHANGELOG.md b/packages/datafile-manager/CHANGELOG.md index 97f4a7b22..35830f8de 100644 --- a/packages/datafile-manager/CHANGELOG.md +++ b/packages/datafile-manager/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] Changes that have landed but are not yet released. +### Changed +- Changed value for node in engines in package.json from >=4.0.0 to >=6.0.0 + ## [0.2.0] - April 9, 2019 ### Changed diff --git a/packages/datafile-manager/package.json b/packages/datafile-manager/package.json index 15d157c85..fdf3a2181 100644 --- a/packages/datafile-manager/package.json +++ b/packages/datafile-manager/package.json @@ -4,7 +4,7 @@ "description": "Optimizely Full Stack Datafile Manager", "license": "Apache-2.0", "engines": { - "node": ">=4.0.0" + "node": ">=6.0.0" }, "main": "lib/index.node.js", "browser": "lib/index.browser.js", From fc74699ed564ece4695c0be5420ac61fe695ce51 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Wed, 24 Apr 2019 21:47:06 -0700 Subject: [PATCH 39/42] Fix quotation marks in CHANGELOG.md code samples --- packages/optimizely-sdk/CHANGELOG.MD | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index 6d6ed5abb..2059fb44d 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -23,7 +23,7 @@ Changes that have landed but are not yet released. ```js const optimizely = require('@optimizely/optimizely-sdk'); const optimizelyClientInstance = optimizely.createInstance({ - sdkKey: ‘12345’, // Provide the sdkKey of your desired environment here + sdkKey: '12345', // Provide the sdkKey of your desired environment here }); ``` @@ -31,7 +31,7 @@ Changes that have landed but are not yet released. ```js const optimizely = require('@optimizely/optimizely-sdk'); const optimizelyClientInstance = optimizely.createInstance({ - sdkKey: ‘12345’, + sdkKey: '12345', }); optimizelyClientInstance.onReady().then(() => { // optimizelyClientInstance is ready to use, with datafile downloaded from the Optimizely CDN @@ -42,7 +42,7 @@ Changes that have landed but are not yet released. ```js const optimizely = require('@optimizely/optimizely-sdk'); const optimizelyClientInstance = optimizely.createInstance({ - sdkKey: ‘12345’, + sdkKey: '12345', datafileOptions: { autoUpdate: true, updateInterval: 600000 // 10 minutes in milliseconds From 8b0fdd9060c3823f48d3f471cc20d392c2504311 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 25 Apr 2019 13:23:26 -0700 Subject: [PATCH 40/42] Undo year update since the file was only moved, not changed --- .../lib/core/project_config/project_config_schema.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js index 7167045f3..a2e96ef6a 100644 --- a/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_schema.js @@ -1,5 +1,5 @@ /** - * Copyright 2016-2019, Optimizely + * Copyright 2016-2017, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From f40530fea19ddcc85dbf591b9a6aed472cb8f47a Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 25 Apr 2019 14:14:56 -0700 Subject: [PATCH 41/42] Fix example import in datafile-manager README --- packages/datafile-manager/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/datafile-manager/README.md b/packages/datafile-manager/README.md index 1553e6f0f..9b8808a52 100644 --- a/packages/datafile-manager/README.md +++ b/packages/datafile-manager/README.md @@ -11,7 +11,7 @@ npm install @optimizely/js-sdk-datafile-manager ## Usage ```js -const { DatafileManager } = require('@optimizely/datafile-manager') +const { DatafileManager } = require('@optimizely/js-sdk-datafile-manager') const manager = new DatafileManager({ sdkKey: '9LCprAQyd1bs1BBXZ3nVji', From 2d33de89325a2a0d669561b7ea0d690b6271fc6d Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 25 Apr 2019 17:55:42 -0700 Subject: [PATCH 42/42] Add change entry to CHANGELOG about forced variation log messages --- packages/optimizely-sdk/CHANGELOG.MD | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index 2059fb44d..0eda1a53b 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -58,6 +58,9 @@ Changes that have landed but are not yet released. optimizelyClientInstance.close(); ``` +### Changed +- Forced variation logic has been moved from the project config module to the decision service. Prefixes for forced-variation-related log messages will reflect this change. + ## [3.1.0] - April 22nd, 2019 ### New Features: