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..35830f8de --- /dev/null +++ b/packages/datafile-manager/CHANGELOG.md @@ -0,0 +1,27 @@ +# 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. + +### 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 +- 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 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..9b8808a52 --- /dev/null +++ b/packages/datafile-manager/README.md @@ -0,0 +1,31 @@ +# Javascript SDK Datafile Manager + +This package provides a datafile manager implementations for Node.js and the browser. + +## Installation + +```sh +npm install @optimizely/js-sdk-datafile-manager +``` + +## Usage + +```js +const { DatafileManager } = require('@optimizely/js-sdk-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__/backoffController.spec.ts b/packages/datafile-manager/__test__/backoffController.spec.ts new file mode 100644 index 000000000..851030c5d --- /dev/null +++ b/packages/datafile-manager/__test__/backoffController.spec.ts @@ -0,0 +1,63 @@ +/** + * 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(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) + 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(512000) + expect(controller.getDelay()).toBeLessThan(513000) + }) + + 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__/browserDatafileManager.spec.ts b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts new file mode 100644 index 000000000..5f2b2492f --- /dev/null +++ b/packages/datafile-manager/__test__/browserDatafileManager.spec.ts @@ -0,0 +1,108 @@ +/** + * 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', + autoUpdate: 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', + autoUpdate: 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() + }) + + 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__/browserRequest.spec.ts b/packages/datafile-manager/__test__/browserRequest.spec.ts new file mode 100644 index 000000000..46f6b3b26 --- /dev/null +++ b/packages/datafile-manager/__test__/browserRequest.spec.ts @@ -0,0 +1,130 @@ +/** + * @jest-environment jsdom + */ +/** + * 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/__test__/eventEmitter.spec.ts b/packages/datafile-manager/__test__/eventEmitter.spec.ts new file mode 100644 index 000000000..b7d393b49 --- /dev/null +++ b/packages/datafile-manager/__test__/eventEmitter.spec.ts @@ -0,0 +1,116 @@ +/** + * 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', () => { + 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..9b4e6f386 --- /dev/null +++ b/packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts @@ -0,0 +1,596 @@ +/** + * 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, 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 | Error)[] = [] + + responsePromises: Promise[] = [] + + makeGetRequest(url: string, headers: Headers): AbortableRequest { + 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) + } + this.responsePromises.push(responsePromise) + return { responsePromise, abort: jest.fn() } + } + + getConfigDefaults(): Partial { + return {} + } +} + +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.clearAllMocks() + jest.restoreAllMocks() + }) + + describe('when constructed with sdkKey and datafile and autoUpdate: true,', () => { + beforeEach(() => { + manager = createTestManager({ datafile: { foo: 'abcd' }, sdkKey: '123', autoUpdate: true }) + }) + + it('returns the passed datafile from get', () => { + expect(manager.get()).toEqual({ foo: 'abcd' }) + }) + + 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() + 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) + }) + }) + + describe('when constructed with sdkKey and autoUpdate: true', () => { + beforeEach(() => { + manager = createTestManager({ sdkKey: '123', updateInterval: 1000, autoUpdate: true }) + }) + + 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()).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', () => { + 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(1000) + }) + + 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()).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()).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()).toEqual({ 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()).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()).toEqual({ 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( + { + 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()).toEqual({ 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()).toEqual({ 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()).toEqual({ 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('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) { + } + }) + }) + }) + }) + }) + + describe('when constructed with sdkKey and autoUpdate: false', () => { + beforeEach(() => { + manager = createTestManager({ sdkKey: '123', autoUpdate: 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()).toEqual({ 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 = () => ({ abort() {}, responsePromise: 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: 1000, + urlTemplate: 'https://localhost:5556/datafiles/%s', + }) + }) + + 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() + }) + }) + + 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/__test__/nodeDatafileManager.spec.ts b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts new file mode 100644 index 000000000..2f561cf2f --- /dev/null +++ b/packages/datafile-manager/__test__/nodeDatafileManager.spec.ts @@ -0,0 +1,110 @@ +/** + * 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', + autoUpdate: 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', + autoUpdate: 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() + }) + + 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/__test__/nodeRequest.spec.ts b/packages/datafile-manager/__test__/nodeRequest.spec.ts new file mode 100644 index 000000000..7674ed50d --- /dev/null +++ b/packages/datafile-manager/__test__/nodeRequest.spec.ts @@ -0,0 +1,160 @@ +/** + * 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() + }) + + 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/__test__/staticDatafileManager.spec.ts b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts new file mode 100644 index 000000000..8ceb3c52b --- /dev/null +++ b/packages/datafile-manager/__test__/staticDatafileManager.spec.ts @@ -0,0 +1,46 @@ +/** + * 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', () => { + 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/__test__/testTimeoutFactory.ts b/packages/datafile-manager/__test__/testTimeoutFactory.ts new file mode 100644 index 000000000..7b1db3667 --- /dev/null +++ b/packages/datafile-manager/__test__/testTimeoutFactory.ts @@ -0,0 +1,37 @@ +/** + * 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 { + 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/jest.config.js b/packages/datafile-manager/jest.config.js new file mode 100644 index 000000000..e1a14d3fc --- /dev/null +++ b/packages/datafile-manager/jest.config.js @@ -0,0 +1,211 @@ +/** + * 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 + +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..fdf3a2181 --- /dev/null +++ b/packages/datafile-manager/package.json @@ -0,0 +1,46 @@ +{ + "name": "@optimizely/js-sdk-datafile-manager", + "version": "0.2.0", + "description": "Optimizely Full Stack Datafile Manager", + "license": "Apache-2.0", + "engines": { + "node": ">=6.0.0" + }, + "main": "lib/index.node.js", + "browser": "lib/index.browser.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib", + "test": "__test__" + }, + "files": [ + "lib", + "LICENSE", + "CHANGELOG", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "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", + "sinon": "^7.2.7", + "ts-jest": "^24.0.0", + "typescript": "^3.3.3333" + }, + "dependencies": { + "@optimizely/js-sdk-logging": "^0.1.0", + "@optimizely/js-sdk-utils": "^0.1.0" + }, + "scripts": { + "test": "jest", + "tsc": "rm -rf lib && tsc", + "prepublishOnly": "yarn test && yarn tsc" + } +} diff --git a/packages/datafile-manager/src/backoffController.ts b/packages/datafile-manager/src/backoffController.ts new file mode 100644 index 000000000..a19650d2f --- /dev/null +++ b/packages/datafile-manager/src/backoffController.ts @@ -0,0 +1,46 @@ +/** + * 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 } 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_BASE_WAIT_SECONDS_BY_ERROR_COUNT.length - 1) { + this.errorCount++ + } + } + + reset(): void { + this.errorCount = 0 + } +} diff --git a/packages/datafile-manager/src/browserDatafileManager.ts b/packages/datafile-manager/src/browserDatafileManager.ts new file mode 100644 index 000000000..3c23f059a --- /dev/null +++ b/packages/datafile-manager/src/browserDatafileManager.ts @@ -0,0 +1,32 @@ +/** + * 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'; +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/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/src/config.ts b/packages/datafile-manager/src/config.ts new file mode 100644 index 000000000..6bdb6aa25 --- /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 = 5 * 60 * 1000 // 5 minutes + +export const MIN_UPDATE_INTERVAL = 1000 + +export const DEFAULT_URL_TEMPLATE = `https://cdn.optimizely.com/datafiles/%s.json` + +export const BACKOFF_BASE_WAIT_SECONDS_BY_ERROR_COUNT = [0, 8, 16, 32, 64, 128, 256, 512] diff --git a/packages/datafile-manager/src/datafileManager.ts b/packages/datafile-manager/src/datafileManager.ts new file mode 100644 index 000000000..a1bb737e5 --- /dev/null +++ b/packages/datafile-manager/src/datafileManager.ts @@ -0,0 +1,47 @@ +/** + * 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 "./timeoutFactory"; + + export interface DatafileUpdate { + datafile: object + } + +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: () => object | null + on: (eventName: string, listener: DatafileUpdateListener) => () => void + onReady: () => Promise +} + +export interface DatafileManagerConfig { + autoUpdate?: boolean + datafile?: object + 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..fb4dece7f --- /dev/null +++ b/packages/datafile-manager/src/http.ts @@ -0,0 +1,37 @@ +/** + * 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. + */ + +/** + * 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 | undefined +} + +export interface Response { + statusCode?: number + 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 new file mode 100644 index 000000000..8ce83b14d --- /dev/null +++ b/packages/datafile-manager/src/httpPollingDatafileManager.ts @@ -0,0 +1,303 @@ +/** + * 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 { 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 } from './config' +import { TimeoutFactory, DEFAULT_TIMEOUT_FACTORY } from './timeoutFactory' +import BackoffController from './backoffController'; + +const logger = getLogger('DatafileManager') + +const UPDATE_EVT = 'update' + +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. + // 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 + + // Return any default configuration options that should be applied + protected abstract getConfigDefaults(): Partial + + private currentDatafile: object | null + + private readonly readyPromise: Promise + + private isReadyPromiseSettled: boolean + + private readyPromiseResolver: () => void + + private readyPromiseRejecter: (err: Error) => void + + private readonly emitter: EventEmitter + + private readonly autoUpdate: boolean + + private readonly updateInterval: number + + private cancelTimeout?: () => void + + private isStarted: boolean + + private lastResponseLastModified?: string + + private datafileUrl: string + + private timeoutFactory: TimeoutFactory + + private currentRequest?: AbortableRequest + + private backoffController: BackoffController + + constructor(config: DatafileManagerConfig) { + const configWithDefaultsApplied: DatafileManagerConfig = { + ...this.getConfigDefaults(), + ...config + } + const { + datafile, + autoUpdate = false, + sdkKey, + timeoutFactory = DEFAULT_TIMEOUT_FACTORY, + updateInterval = DEFAULT_UPDATE_INTERVAL, + urlTemplate = DEFAULT_URL_TEMPLATE, + } = configWithDefaultsApplied + + this.isReadyPromiseSettled = false + this.readyPromiseResolver = () => {} + this.readyPromiseRejecter = () => {} + this.readyPromise = new Promise((resolve, reject) => { + this.readyPromiseResolver = resolve + this.readyPromiseRejecter = reject + }) + + if (datafile) { + this.currentDatafile = datafile + this.resolveReadyPromise() + } else { + this.currentDatafile = null + } + + this.isStarted = false + + this.datafileUrl = sprintf(urlTemplate, sdkKey) + + this.timeoutFactory = timeoutFactory + this.emitter = new EventEmitter() + this.autoUpdate = autoUpdate + if (isValidUpdateInterval(updateInterval)) { + this.updateInterval = updateInterval + } else { + logger.warn('Invalid updateInterval %s, defaulting to %s', updateInterval, DEFAULT_UPDATE_INTERVAL) + this.updateInterval = DEFAULT_UPDATE_INTERVAL + } + this.backoffController = new BackoffController() + } + + get(): object | null { + return this.currentDatafile + } + + start(): void { + if (!this.isStarted) { + logger.debug('Datafile manager started') + this.isStarted = true + this.backoffController.reset() + this.syncDatafile() + } + } + + stop(): Promise { + logger.debug('Datafile manager stopped') + this.isStarted = false + if (this.cancelTimeout) { + this.cancelTimeout() + this.cancelTimeout = undefined + } + + this.emitter.removeAllListeners() + + if (this.currentRequest) { + this.currentRequest.abort() + this.currentRequest = undefined + } + + return Promise.resolve() + } + + onReady(): Promise { + return this.readyPromise + } + + on(eventName: string, listener: (datafileUpdate: DatafileUpdate) => void) { + return this.emitter.on(eventName, listener) + } + + 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') { + logger.error('Error fetching datafile: %s', err) + } else { + logger.error('Error fetching datafile') + } + } + + 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) + if (datafile !== null) { + logger.info('Updating datafile from response') + this.currentDatafile = datafile + if (!this.isReadyPromiseSettled) { + this.resolveReadyPromise() + } else { + const datafileUpdate: DatafileUpdate = { + datafile, + } + this.emitter.emit(UPDATE_EVT, datafileUpdate) + } + } + } + + private onRequestComplete(this: HTTPPollingDatafileManager): void { + if (!this.isStarted) { + return + } + + this.currentRequest = undefined + + if (this.autoUpdate) { + this.scheduleNextUpdate() + } + if (!this.isReadyPromiseSettled && !this.autoUpdate) { + // We will never resolve ready, so reject it + this.rejectReadyPromise(new Error('Failed to become ready')) + } + } + + private syncDatafile(): void { + const headers: Headers = {} + if (this.lastResponseLastModified) { + headers['if-modified-since'] = this.lastResponseLastModified + } + + 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() + } + const onRequestResolved = (response: Response) => { + this.onRequestResolved(response) + } + const onRequestRejected = (err: any) => { + this.onRequestRejected(err) + } + this.currentRequest.responsePromise + .then(onRequestResolved, onRequestRejected) + .then(onRequestComplete, onRequestComplete) + } + + private resolveReadyPromise(): void { + this.readyPromiseResolver() + this.isReadyPromiseSettled = true + } + + private rejectReadyPromise(err: Error): void { + this.readyPromiseRejecter(err) + this.isReadyPromiseSettled = true + } + + private scheduleNextUpdate(): void { + 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() + }, nextUpdateDelay) + } + + private getNextDatafileFromResponse(response: Response): object | null { + logger.debug('Response status code: %s', response.statusCode) + if (typeof response.statusCode === 'undefined') { + return null + } + if (response.statusCode === 304) { + return null + } + if (isSuccessStatusCode(response.statusCode)) { + 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') { + this.lastResponseLastModified = lastModifiedHeader + logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified) + } + } +} diff --git a/packages/datafile-manager/src/index.browser.ts b/packages/datafile-manager/src/index.browser.ts new file mode 100644 index 000000000..f83d01a9b --- /dev/null +++ b/packages/datafile-manager/src/index.browser.ts @@ -0,0 +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 * 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 new file mode 100644 index 000000000..e53dbbd18 --- /dev/null +++ b/packages/datafile-manager/src/index.node.ts @@ -0,0 +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 * from './datafileManager' +export { default as DatafileManager } from './nodeDatafileManager' +export { default as StaticDatafileManager } from './staticDatafileManager'; diff --git a/packages/datafile-manager/src/nodeDatafileManager.ts b/packages/datafile-manager/src/nodeDatafileManager.ts new file mode 100644 index 000000000..6672bc2dc --- /dev/null +++ b/packages/datafile-manager/src/nodeDatafileManager.ts @@ -0,0 +1,32 @@ +/** + * 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'; +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/nodeRequest.ts b/packages/datafile-manager/src/nodeRequest.ts new file mode 100644 index 000000000..ee0cfa2ff --- /dev/null +++ b/packages/datafile-manager/src/nodeRequest.ts @@ -0,0 +1,141 @@ +/** + * 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 { + hostname: url.hostname, + path: url.path, + port: url.port, + protocol: url.protocol, + } +} + +/** + * 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 + 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: createHeadersFromNodeIncomingMessage(incomingMessage), + }) + }) + }) + + 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/src/staticDatafileManager.ts b/packages/datafile-manager/src/staticDatafileManager.ts new file mode 100644 index 000000000..96e0674df --- /dev/null +++ b/packages/datafile-manager/src/staticDatafileManager.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 { 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 + } +} diff --git a/packages/datafile-manager/src/timeoutFactory.ts b/packages/datafile-manager/src/timeoutFactory.ts new file mode 100644 index 000000000..a9dff614f --- /dev/null +++ b/packages/datafile-manager/src/timeoutFactory.ts @@ -0,0 +1,28 @@ +/** + * 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 +} + +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..8f434abd1 --- /dev/null +++ b/packages/datafile-manager/yarn.lock @@ -0,0 +1,3635 @@ +# 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" + +"@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" + 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/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.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" + 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" + 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-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" + 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= + +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" + 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= + +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" + 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" + +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" + 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-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" + 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== + +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" + 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-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" + 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@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" + 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, 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" + +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" + 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, 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== + +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" + 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== + +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" + 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" + 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-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" + integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== + 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" + 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" + +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" + 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.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" + 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= + +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" + 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, 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== + 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" + +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== + +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" diff --git a/packages/optimizely-sdk/CHANGELOG.MD b/packages/optimizely-sdk/CHANGELOG.MD index d9ecc0157..0eda1a53b 100644 --- a/packages/optimizely-sdk/CHANGELOG.MD +++ b/packages/optimizely-sdk/CHANGELOG.MD @@ -7,6 +7,60 @@ 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. + - 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 + + + #### Create an instance with datafile management enabled + ```js + const optimizely = require('@optimizely/optimizely-sdk'); + const optimizelyClientInstance = optimizely.createInstance({ + sdkKey: '12345', // Provide the sdkKey of your desired environment here + }); + ``` + + #### Use `onReady` to wait until optimizelyClientInstance has a datafile + ```js + const optimizely = require('@optimizely/optimizely-sdk'); + const optimizelyClientInstance = optimizely.createInstance({ + sdkKey: '12345', + }); + 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 optimizelyClientInstance = optimizely.createInstance({ + sdkKey: '12345', + datafileOptions: { + autoUpdate: true, + updateInterval: 600000 // 10 minutes in milliseconds + }, + }); + optimizelyClientInstance.notificationCenter.addNotificationListener( + optimizely.enums.NOTIFICATION_TYPES.OPTIMIZELY_CONFIG_UPDATE, + () => { + // optimizelyClientInstance has updated its Optimizely config + }, + ); + // Stop automatic updates - optimizelyClientInstance will use whatever datafile it currently has from now on + 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: 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 2b7135001..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,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'; @@ -87,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 = {}; @@ -296,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. @@ -607,7 +464,7 @@ module.exports = { }, /** - * + * * @param {Object} projectConfig * @param {string} experimentId * @returns {boolean} Returns true if experiment belongs to @@ -615,5 +472,27 @@ module.exports = { */ isFeatureExperiment: function(projectConfig, experimentId) { return projectConfig.experimentFeatureMap.hasOwnProperty(experimentId); - } + }, + + /** + * Try to create a project config object from the given datafile and + * configuration properties. + * If successful, return the project config object, otherwise throws an error + * @param {Object} config + * @param {Object} config.datafile + * @param {Object} config.jsonSchemaValidator + * @param {Object} config.logger + * @param {Object} config.skipJSONValidation + * @return {Object} Project config object + */ + tryCreatingProjectConfig: function(config) { + 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 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 f840b4e4d..6e6e3b23e 100644 --- a/packages/optimizely-sdk/lib/core/project_config/index.tests.js +++ b/packages/optimizely-sdk/lib/core/project_config/index.tests.js @@ -16,12 +16,16 @@ 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; @@ -223,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); @@ -350,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'); @@ -557,174 +561,95 @@ describe('lib/core/project_config', function() { }); }); - describe('#getForcedVariation', function() { - var createdLogger = logger.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 = logger.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); + describe('#tryCreatingProjectConfig', function() { + var stubJsonSchemaValidator; + beforeEach(function() { + stubJsonSchemaValidator = { + validate: sinon.stub().returns(true), + }; + sinon.stub(projectConfig, 'createProjectConfig').returns({}); + sinon.stub(configValidator, 'validateDatafile').returns(true); + sinon.spy(logger, 'error'); }); - 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'); + 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() { + 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' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: false, + }); + assert.deepEqual(result, configObj); }); - 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('throws an error when validateDatafile throws', function() { + configValidator.validateDatafile.throws(); + stubJsonSchemaValidator.validate.returns(true); + assert.throws(function() { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: false, + }); + }); }); - 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('throws an error when jsonSchemaValidator.validate throws', function() { + configValidator.validateDatafile.returns(true); + stubJsonSchemaValidator.validate.throws(); + assert.throws(function() { + var result = projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: false, + }); + }); }); - 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('does not call jsonSchemaValidator.validate when skipJSONValidation is true', function() { + projectConfig.tryCreatingProjectConfig({ + datafile: { foo: 'bar' }, + jsonSchemaValidator: stubJsonSchemaValidator, + logger: logger, + skipJSONValidation: true, + }); + sinon.assert.notCalled(stubJsonSchemaValidator.validate); }); - 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); + 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' }, + logger: logger, + }); + assert.deepEqual(result, configObj); + 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..2a6eddb2b --- /dev/null +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.js @@ -0,0 +1,340 @@ +/** + * 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'; + +/** + * 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 + * 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.resolve({ + success: false, + reason: getErrorMessage(ex, 'Error in initialize'), + }); + } +} + +/** + * 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.resolve({ + success: false, + reason: getErrorMessage(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 && this.__configObj) { + datafileManagerConfig.datafile = initialDatafile; + } + this.datafileManager = new datafileManager.DatafileManager(datafileManagerConfig); + this.datafileManager.start(); + 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({ + success: true, + }); + } else { + this.__readyPromise = Promise.resolve({ + success: false, + reason: getErrorMessage(projectConfigCreationEx, 'Invalid datafile'), + }); + } +}; + +/** + * 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.__onDatafileManagerReadyFulfill = 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); + 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'), + }; +}; + +/** + * 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.__handleNewConfigObj(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; +}; + +/** + * 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 + * @return {Object|null} + */ +ProjectConfigManager.prototype.getConfig = function() { + return this.__configObj; +}; + +/** + * 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() { + 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); + } + }.bind(this); +}; + +/** + * 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..b405b83e4 --- /dev/null +++ b/packages/optimizely-sdk/lib/core/project_config/project_config_manager.tests.js @@ -0,0 +1,411 @@ +/** + * 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 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')); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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, + 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')); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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({ + 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')); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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, + }); + 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')); + return manager.onReady().then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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 with a successful result', function() { + var configWithFeatures = testData.getTestProjectConfigWithFeatures(); + var manager = new projectConfigManager.ProjectConfigManager({ + datafile: configWithFeatures, + }); + assert.deepEqual( + manager.getConfig(), + projectConfig.createProjectConfig(configWithFeatures) + ); + 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() { + 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({ + 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, + })); + }); + + describe('when constructed with sdkKey and without datafile', 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(), + 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(result) { + assert.include(result, { + success: true, + }); + 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' }], + }); + 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) + ); + }); + }); + + 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); + + 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); + }); + }); + + 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('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({ + 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', + }); + 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, + }); + }); + }); + + 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); + }); + }); + + 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(), + 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(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/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/index.browser.tests.js b/packages/optimizely-sdk/lib/index.browser.tests.js index 536dee85a..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. @@ -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'); @@ -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.browser.umdtests.js b/packages/optimizely-sdk/lib/index.browser.umdtests.js index 32181928e..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. @@ -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() { diff --git a/packages/optimizely-sdk/lib/index.node.tests.js b/packages/optimizely-sdk/lib/index.node.tests.js index 589e6c29b..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. @@ -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'); diff --git a/packages/optimizely-sdk/lib/optimizely/index.js b/packages/optimizely-sdk/lib/optimizely/index.js index 48b468247..18927ccaa 100644 --- a/packages/optimizely-sdk/lib/optimizely/index.js +++ b/packages/optimizely-sdk/lib/optimizely/index.js @@ -24,11 +24,10 @@ 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'); -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 @@ -67,29 +67,23 @@ function Optimizely(config) { this.clientVersion = config.clientVersion || enums.NODE_CLIENT_VERSION; this.errorHandler = config.errorHandler; this.eventDispatcher = 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); - } + this.projectConfigManager = new projectConfigManager.ProjectConfigManager({ + datafile: config.datafile, + datafileOptions: config.datafileOptions, + jsonSchemaValidator: config.jsonSchemaValidator, + sdkKey: config.sdkKey, + skipJSONValidation: config.skipJSONValidation, + }); - 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.__disposeOnUpdate = this.projectConfigManager.onUpdate(function(configObj) { + 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(); var userProfileService = null; if (config.userProfileService) { @@ -119,8 +113,21 @@ function Optimizely(config) { maxQueueSize: config.eventBatchSize || DEFAULT_EVENT_MAX_QUEUE_SIZE, }); this.eventProcessor.start(); + + this.__readyTimeouts = {}; + this.__nextReadyTimeoutId = 0; } +/** + * 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.projectConfigManager.getConfig(); +}; + /** * Buckets visitor and sends impression event to Optimizely. * @param {string} experimentKey @@ -130,7 +137,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; } @@ -139,6 +146,11 @@ Optimizely.prototype.activate = function(experimentKey, userId, attributes) { return this.__notActivatingExperiment(experimentKey, userId); } + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return null; + } + try { var variationKey = this.getVariation(experimentKey, userId, attributes); if (variationKey === null) { @@ -146,7 +158,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, @@ -183,6 +195,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.projectConfigManager.getConfig(); + if (!configObj) { + return; + } + var impressionEvent = eventHelpers.buildImpressionEvent({ experimentKey: experimentKey, variationKey: variationKey, @@ -190,7 +207,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); @@ -205,24 +222,29 @@ Optimizely.prototype._sendImpressionEvent = function(experimentKey, variationKey * @param {Object} attributes Optional user attributes */ Optimizely.prototype.__emitNotificationCenterActivate = function(experimentKey, variationKey, userId, attributes) { + var configObj = this.projectConfigManager.getConfig(); + 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]; @@ -248,7 +270,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; } @@ -257,7 +279,12 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { return; } - if (!projectConfig.eventWithKeyExists(this.configObj, eventKey)) { + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return; + } + + if (!projectConfig.eventWithKeyExists(configObj, eventKey)) { throw new Error(sprintf(ERROR_MESSAGES.INVALID_EVENT_KEY, MODULE_NAME, eventKey)); } @@ -270,7 +297,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); @@ -292,11 +319,16 @@ Optimizely.prototype.track = function(eventKey, userId, attributes, eventTags) { */ Optimizely.prototype.__emitNotificationCenterTrack = function(eventKey, userId, attributes, eventTags) { try { + var configObj = this.projectConfigManager.getConfig(); + 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, @@ -326,7 +358,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; } @@ -336,14 +368,19 @@ Optimizely.prototype.getVariation = function(experimentKey, userId, attributes) return null; } - var experiment = this.configObj.experimentKeyMap[experimentKey]; + var configObj = this.projectConfigManager.getConfig(); + 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 decisionNotificationType = projectConfig.isFeatureExperiment(this.configObj, experiment.id) ? DECISION_NOTIFICATION_TYPES.FEATURE_TEST : + var variationKey = this.decisionService.getVariation(configObj, experimentKey, userId, attributes); + var decisionNotificationType = projectConfig.isFeatureExperiment(configObj, experiment.id) ? DECISION_NOTIFICATION_TYPES.FEATURE_TEST : DECISION_NOTIFICATION_TYPES.AB_TEST; this.notificationCenter.sendNotifications( @@ -384,8 +421,13 @@ Optimizely.prototype.setForcedVariation = function(experimentKey, userId, variat return false; } + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return false; + } + try { - return projectConfig.setForcedVariation(this.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); @@ -404,8 +446,13 @@ Optimizely.prototype.getForcedVariation = function(experimentKey, userId) { return null; } + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return null; + } + try { - return projectConfig.getForcedVariation(this.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); @@ -489,7 +536,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; } @@ -498,13 +545,18 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) return false; } - var feature = projectConfig.getFeatureFromKey(this.configObj, featureKey, this.logger); + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return false; + } + + var feature = projectConfig.getFeatureFromKey(configObj, featureKey, this.logger); if (!feature) { return false; } var featureEnabled = false; - var decision = this.decisionService.getVariationForFeature(this.configObj, feature, userId, attributes); + var decision = this.decisionService.getVariationForFeature(configObj, feature, userId, attributes); var variation = decision.variation; var sourceInfo = {}; @@ -519,7 +571,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 { @@ -533,7 +585,7 @@ Optimizely.prototype.isFeatureEnabled = function(featureKey, userId, attributes) source: decision.decisionSource, sourceInfo: sourceInfo }; - + this.notificationCenter.sendNotifications( NOTIFICATION_TYPES.DECISION, { @@ -562,7 +614,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; } @@ -571,8 +623,13 @@ Optimizely.prototype.getEnabledFeatures = function(userId, attributes) { return enabledFeatures; } + var configObj = this.projectConfigManager.getConfig(); + if (!configObj) { + return enabledFeatures; + } + fns.forOwn( - this.configObj.featureKeyMap, + configObj.featureKeyMap, function(feature) { if (this.isFeatureEnabled(feature.key, userId, attributes)) { enabledFeatures.push(feature.key); @@ -609,7 +666,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; @@ -619,12 +676,17 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK return null; } - var featureFlag = projectConfig.getFeatureFromKey(this.configObj, featureKey, this.logger); + var configObj = this.projectConfigManager.getConfig(); + 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; } @@ -639,11 +701,11 @@ Optimizely.prototype._getFeatureVariableForType = function(featureKey, variableK var featureEnabled = false; var variableValue = variable.defaultValue; - var decision = this.decisionService.getVariationForFeature(this.configObj, featureFlag, userId, attributes); - + var decision = this.decisionService.getVariationForFeature(configObj, featureFlag, userId, attributes); + if (decision.variation !== null) { featureEnabled = decision.variation.featureEnabled; - var value = projectConfig.getVariableValueForVariation(this.configObj, variable, decision.variation, this.logger); + var value = projectConfig.getVariableValueForVariation(configObj, variable, decision.variation, this.logger); if (value !== null) { if (featureEnabled === true) { variableValue = value; @@ -791,10 +853,90 @@ 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(); + } + 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); } }; +/** + * 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), 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 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, + * datafile validation errors, or the instance being + * closed + * @param {Object=} options + * @param {number|undefined} options.timeout + * @return {Promise} + */ +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 resolveTimeoutPromise; + var timeoutPromise = new Promise(function(resolve) { + 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]); +}; + module.exports = Optimizely; diff --git a/packages/optimizely-sdk/lib/optimizely/index.tests.js b/packages/optimizely-sdk/lib/optimizely/index.tests.js index 1bb59797b..c4acf4919 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 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'); @@ -29,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; @@ -44,6 +46,36 @@ var DECISION_NOTIFICATION_TYPES = enums.DECISION_NOTIFICATION_TYPES; var FEATURE_VARIABLE_TYPES = enums.FEATURE_VARIABLE_TYPES; describe('lib/optimizely', function() { + var ProjectConfigManagerStub; + var globalStubErrorHandler; + var stubLogHandler; + beforeEach(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 { + stop: sinon.stub(), + getConfig: sinon.stub().returns(currentConfig), + onUpdate: sinon.stub().returns(function() {}), + onReady: sinon.stub().returns({ then: function() {} }) + }; + }); + }); + + afterEach(function() { + ProjectConfigManagerStub.restore(); + logging.resetErrorHandler(); + logging.resetLogger(); + }); + describe('constructor', function() { var stubErrorHandler = { handleError: function() {}}; var stubEventDispatcher = { dispatchEvent: function() { return bluebird.resolve(null); } }; @@ -69,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, 'OPTIMIZELY')); }); it('should construct an instance of the Optimizely class when datafile is JSON string', function() { @@ -84,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, 'OPTIMIZELY')); }); it('should log if the client engine passed in is invalid', function() { @@ -102,117 +128,6 @@ 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() { - 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.NO_DATAFILE_SPECIFIED, 'CONFIG_VALIDATOR')); - - sinon.assert.calledOnce(createdLogger.log); - var logMessage = createdLogger.log.args[0][1]; - assert.strictEqual(logMessage, sprintf(ERROR_MESSAGES.NO_DATAFILE_SPECIFIED, 'CONFIG_VALIDATOR')); - - 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, 'OPTIMIZELY')); - }); - }); - describe('when a user profile service is provided', function() { beforeEach(function() { sinon.stub(decisionService, 'createDecisionService'); @@ -241,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.'); }); @@ -264,11 +178,56 @@ 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\'.'); }); }); + + 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('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, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + skipJSONValidation: false, + }); + sinon.assert.calledOnce(projectConfigManager.ProjectConfigManager); + sinon.assert.calledWithExactly(projectConfigManager.ProjectConfigManager, { + datafile: testData.getTestProjectConfig(), + datafileOptions: { + autoUpdate: true, + updateInterval: 2 * 60 * 1000, + }, + jsonSchemaValidator: jsonSchemaValidator, + sdkKey: '12345', + skipJSONValidation: false, + }); + }); + }); }); }); @@ -630,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')); @@ -646,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( @@ -668,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( @@ -773,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')); @@ -1447,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') ); }); @@ -1476,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( @@ -1547,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')); }); @@ -1606,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() { @@ -1648,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() { @@ -1684,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() { @@ -1710,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() { @@ -1729,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() { @@ -1785,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() { @@ -1793,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() { @@ -1804,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')); @@ -2086,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', @@ -2151,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', @@ -2490,18 +2449,18 @@ 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; + var experiment = optlyInstance.projectConfigManager.getConfig().experimentKeyMap.testing_my_feature; var variation = experiment.variations[0]; sandbox.stub(optlyInstance.decisionService, 'getVariationForFeature').returns({ experiment: experiment, @@ -2509,7 +2468,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.FEATURE_TEST, }); }); - + it('should return true and send notification', function() { var result = optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user1', attributes); assert.strictEqual(result, true); @@ -2532,7 +2491,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, @@ -2566,7 +2525,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, @@ -2574,7 +2533,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', @@ -2593,11 +2552,11 @@ 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, @@ -2605,14 +2564,14 @@ 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', }); 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_NOTIFICATION_TYPES.FEATURE, userId: 'user1', @@ -2638,7 +2597,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); @@ -2661,7 +2620,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, @@ -2669,7 +2628,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.FEATURE_TEST, }); }); - + 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); @@ -2691,7 +2650,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); @@ -2713,7 +2672,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); @@ -2735,7 +2694,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'); @@ -2758,10 +2717,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, @@ -2769,7 +2728,7 @@ describe('lib/optimizely', function() { decisionSource: DECISION_SOURCES.FEATURE_TEST, }); }); - + 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); @@ -2791,7 +2750,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); @@ -2813,7 +2772,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); @@ -2835,7 +2794,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'); @@ -2863,7 +2822,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, @@ -2871,7 +2830,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); @@ -2890,7 +2849,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); @@ -2909,7 +2868,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); @@ -2928,7 +2887,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'); @@ -2948,10 +2907,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, @@ -2959,7 +2918,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); @@ -2978,7 +2937,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); @@ -2997,7 +2956,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); @@ -3016,7 +2975,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'); @@ -3046,7 +3005,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); @@ -3065,7 +3024,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); @@ -3084,7 +3043,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); @@ -3103,7 +3062,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'); @@ -3253,7 +3212,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, @@ -3266,10 +3225,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 @@ -3423,7 +3382,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, @@ -3436,10 +3395,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 @@ -3506,7 +3465,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({ @@ -3520,10 +3479,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 @@ -3536,7 +3495,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, @@ -3558,7 +3517,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, @@ -3682,7 +3641,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); @@ -3780,7 +3739,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, @@ -3817,25 +3776,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'); @@ -3846,7 +3805,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, @@ -3884,7 +3843,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, @@ -3921,25 +3880,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'); @@ -3950,7 +3909,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, @@ -4431,8 +4390,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 ); @@ -4449,8 +4408,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 ); @@ -4483,8 +4442,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 ); @@ -4499,8 +4458,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 ); @@ -4516,8 +4475,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 ); @@ -4530,8 +4489,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 ); @@ -4799,4 +4758,273 @@ describe('lib/optimizely', function() { }); }); }); + + describe('project config 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; + + 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({ + 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.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'), []); + 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 clock; + beforeEach(function() { + clock = sinon.useFakeTimers(new Date().getTime()); + }); + + afterEach(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', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + var readyPromise = optlyInstance.onReady({ timeout: 500 }); + clock.tick(501); + return readyPromise.then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + var readyPromise = optlyInstance.onReady(); + clock.tick(300001); + return readyPromise.then(function(result) { + assert.include(result, { + success: false, + }); + }); + }); + + 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() { + 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); + + optlyInstance = new Optimizely({ + clientEngine: 'node-sdk', + errorHandler: errorHandler, + eventDispatcher: eventDispatcher, + jsonSchemaValidator: jsonSchemaValidator, + logger: createdLogger, + sdkKey: '12345', + isValidInstance: true, + }); + }); + + 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')); + + // 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); + + // With the new project config containing this feature, should return true + assert.isTrue(optlyInstance.isFeatureEnabled('test_feature_for_experiment', 'user45678')); + + // 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); + + // 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.OPTIMIZELY_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 dc4fe041a..84bc3194a 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.', @@ -135,6 +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_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,6 +179,9 @@ exports.NODE_CLIENT_VERSION = '3.1.0'; * - attributes {Object|undefined} * - decisionInfo {Object|undefined} * + * OPTIMIZELY_CONFIG_UPDATE: This Optimizely instance has been updated with a new + * config + * * TRACK: A conversion event will be sent to Optimizely * Callbacks will receive the an object argument with the following properties: * - eventKey {string} @@ -184,10 +189,12 @@ exports.NODE_CLIENT_VERSION = '3.1.0'; * - attributes {Object|undefined} * - eventTags {Object|undefined} * - logEvent {Object} + * */ exports.NOTIFICATION_TYPES = { ACTIVATE: 'ACTIVATE:experiment, user_id,attributes, variation, event', DECISION: 'DECISION:type, userId, attributes, decisionInfo', + OPTIMIZELY_CONFIG_UPDATE: 'OPTIMIZELY_CONFIG_UPDATE', TRACK: 'TRACK:event_key, user_id, attributes, event_tags, event', }; 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..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. @@ -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 34a85a30d..c030fa634 100644 --- a/packages/optimizely-sdk/package-lock.json +++ b/packages/optimizely-sdk/package-lock.json @@ -4,6 +4,15 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@optimizely/js-sdk-datafile-manager": { + "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-utils": "^0.1.0" + } + }, "@optimizely/js-sdk-event-processor": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.2.0.tgz", diff --git a/packages/optimizely-sdk/package.json b/packages/optimizely-sdk/package.json index 7d51acdab..3bddb6b80 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.2.0", "@optimizely/js-sdk-event-processor": "^0.2.0", "@optimizely/js-sdk-logging": "^0.1.0", "@optimizely/js-sdk-utils": "^0.1.0",