diff --git a/CHANGELOG.md b/CHANGELOG.md index e03cae64..5a548ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the LaunchDarkly client-side JavaScript SDKs will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [2.10.0] - 2019-04-19 +### Added: +- Generated TypeDoc documentation for all types, properties, and methods is now available online at [https://launchdarkly.github.io/js-client/](https://launchdarkly.github.io/js-client/). Currently this will only be for the latest released version. +- The SDK now allows you to specify an anonymous user without a key (i.e. the `anonymous` property is `true`, and there is no `key` property). In that case, the SDK will generate a UUID and send that as the user key. It will also cache this generated key in local storage (if local storage is available) so that anonymous users in the same browser will always get the same key. + +### Fixed: +- Setting user attributes to non-string values when a string was expected would prevent evaluations and analytics events from working. The SDK will now convert attribute values to strings as needed. + ## [2.9.7] - 2019-04-16 ### Fixed: - If there are pending analytics events when the page is being closed, the SDK normally attempts to deliver them by making a synchronous HTTP request. Chrome, as of version 73, does not allow this and logs an error. An upcoming release will change how events are sent, but as a temporary measure to avoid these errors, the SDK will now simply discard any pending events when the page is being closed _if_ the browser is Chrome version 73 or higher. In other browsers, there is no change. Note that this means that in Chrome 73, some events may be lost; that was already the case. The purpose of this patch is simply to avoid triggering errors. ([#178](https://github.com/launchdarkly/js-client-private/pull/178)) diff --git a/README.md b/README.md index 9b6cf671..cb7f2c32 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ var user = { key: 'user.example.com' }; var client = LDClient.initialize('YOUR_CLIENT_SIDE_ID', user); ``` -The user object can contain any of the properties described [here](https://docs.launchdarkly.com/docs/targeting-users). The SDK always has a single current user; you can change it after initialization (see "Changing users"). +The user object can contain any of the properties described [here](https://docs.launchdarkly.com/docs/targeting-users). The SDK always has a single current user; you can change it after initialization (see "Changing users"). If you want the SDK to generate a unique key for the user, omit the `key` property and set the `anonymous` property to `true`. The client is initialized asynchronously, so if you want to determine when the client is ready to evaluate feature flags, use the `ready` event, or the Promise-based method `waitForInitialization()`: @@ -305,7 +305,7 @@ client.identify(newUser, hash, function() { For an additional overview with code samples, see the online [JavaScript SDK Reference](https://docs.launchdarkly.com/docs/js-sdk-reference). -The authoritative full description of all properties and methods is in the TypeScript declaration files for [`ldclient-js`](https://github.com/launchdarkly/js-client/blob/master/packages/ldclient-js/typings.d.ts) and [`ldclient-js-common`](https://github.com/launchdarkly/js-client/blob/master/packages/ldclient-js-common/typings.d.ts) (a common package used by LaunchDarkly's JavaScript, React, and Electron SDKs). +The authoritative full description of all properties, types, and methods is the [online TypeScript documentation](https://launchdarkly.github.io/js-client/). If you are not using TypeScript, then the types are only for your information and are not enforced, although the properties and methods are still the same as described in the documentation. For examples of using the SDK in a simple JavaScript application, see [`hello-js`](https://github.com/launchdarkly/hello-js) and [`hello-bootstrap`](https://github.com/launchdarkly/hello-bootstrap). diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..8c57d00a --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,22 @@ +jobs: + - job: build + pool: + vmImage: 'vs2017-win2016' + steps: + - task: PowerShell@2 + displayName: 'install dependencies' + inputs: + targetType: inline + script: | + node --version + npm install + - task: PowerShell@2 + displayName: 'build' + inputs: + targetType: inline + script: npm run build + - task: PowerShell@2 + displayName: 'test' + inputs: + targetType: inline + script: npm test diff --git a/docs/typedoc.js b/docs/typedoc.js index 3d1dda7d..16db8794 100644 --- a/docs/typedoc.js +++ b/docs/typedoc.js @@ -3,9 +3,15 @@ // the properties are equivalent to the command-line options described here: // https://typedoc.org/api/ +let version = process.env.VERSION; +if (!version) { + const package = require('../packages/ldclient-js/package.json'); + version = package.version; +} + module.exports = { out: './build/html', - name: 'LaunchDarkly JavaScript SDK', + name: 'LaunchDarkly JavaScript SDK (' + version + ')', readme: 'none', // don't add a home page with a copy of README.md mode: 'file', // don't treat "typings.d.ts" itself as a parent module includeDeclarations: true, // allows it to process a .d.ts file instead of actual TS code diff --git a/packages/ldclient-js-common/package-lock.json b/packages/ldclient-js-common/package-lock.json index b2a20cd4..9720c3fb 100644 --- a/packages/ldclient-js-common/package-lock.json +++ b/packages/ldclient-js-common/package-lock.json @@ -1,6 +1,6 @@ { "name": "ldclient-js-common", - "version": "2.9.6", + "version": "2.9.7", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4572,7 +4572,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -8806,8 +8806,7 @@ "uuid": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", - "dev": true + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" }, "validate-npm-package-license": { "version": "3.0.4", @@ -8860,7 +8859,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true } diff --git a/packages/ldclient-js-common/package.json b/packages/ldclient-js-common/package.json index 26389b71..0beee858 100644 --- a/packages/ldclient-js-common/package.json +++ b/packages/ldclient-js-common/package.json @@ -61,7 +61,8 @@ }, "dependencies": { "base64-js": "1.3.0", - "fast-deep-equal": "2.0.1" + "fast-deep-equal": "2.0.1", + "uuid": "3.3.2" }, "repository": { "type": "git", diff --git a/packages/ldclient-js-common/src/EventProcessor.js b/packages/ldclient-js-common/src/EventProcessor.js index 5dccdbdf..08d95c92 100644 --- a/packages/ldclient-js-common/src/EventProcessor.js +++ b/packages/ldclient-js-common/src/EventProcessor.js @@ -83,7 +83,7 @@ export default function EventProcessor(platform, options, environmentId, logger, } }; - processor.flush = function(sync) { + processor.flush = function() { if (disabled) { return Promise.resolve(); } @@ -99,7 +99,7 @@ export default function EventProcessor(platform, options, environmentId, logger, } queue = []; logger.debug(messages.debugPostingEvents(eventsToSend.length)); - return eventSender.sendEvents(eventsToSend, sync).then(responseInfo => { + return eventSender.sendEvents(eventsToSend).then(responseInfo => { if (responseInfo) { if (responseInfo.serverTime) { lastKnownPastTime = responseInfo.serverTime; diff --git a/packages/ldclient-js-common/src/EventSender.js b/packages/ldclient-js-common/src/EventSender.js index 17c69acc..43d1068e 100644 --- a/packages/ldclient-js-common/src/EventSender.js +++ b/packages/ldclient-js-common/src/EventSender.js @@ -8,17 +8,14 @@ export default function EventSender(platform, eventsUrl, environmentId, imageCre const imageUrl = eventsUrl + '/a/' + environmentId + '.gif'; const sender = {}; - function loadUrlUsingImage(src, onDone) { + function loadUrlUsingImage(src) { const img = new window.Image(); - if (onDone) { - img.addEventListener('load', onDone); - } img.src = src; } - function getResponseInfo(xhr) { - const ret = { status: xhr.status }; - const dateStr = xhr.getResponseHeader('Date'); + function getResponseInfo(result) { + const ret = { status: result.status }; + const dateStr = result.header('date'); if (dateStr) { const time = Date.parse(dateStr); if (time) { @@ -28,59 +25,55 @@ export default function EventSender(platform, eventsUrl, environmentId, imageCre return ret; } - function sendChunk(events, usePost, sync) { + function sendChunk(events, usePost) { const createImage = imageCreator || loadUrlUsingImage; const jsonBody = JSON.stringify(events); - const send = onDone => { - function createRequest(canRetry) { - const xhr = platform.newHttpRequest(); - xhr.open('POST', postUrl, !sync); - utils.addLDHeaders(xhr, platform); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.setRequestHeader('X-LaunchDarkly-Event-Schema', '3'); - if (!sync) { - xhr.addEventListener('load', () => { - if (xhr.status >= 400 && errors.isHttpErrorRecoverable(xhr.status) && canRetry) { - createRequest(false).send(jsonBody); - } else { - onDone(getResponseInfo(xhr)); - } - }); + + function doPostRequest(canRetry) { + const headers = utils.extend( + { + 'Content-Type': 'application/json', + 'X-LaunchDarkly-Event-Schema': '3', + }, + utils.getLDHeaders(platform) + ); + return platform + .httpRequest('POST', postUrl, headers, jsonBody) + .promise.then(result => { + if (!result) { + // This was a response from a fire-and-forget request, so we won't have a status. + return; + } + if (result.status >= 400 && errors.isHttpErrorRecoverable(result.status) && canRetry) { + return doPostRequest(false); + } else { + return getResponseInfo(result); + } + }) + .catch(() => { if (canRetry) { - xhr.addEventListener('error', () => { - createRequest(false).send(jsonBody); - }); + return doPostRequest(false); } - } - return xhr; - } - if (usePost) { - createRequest(true).send(jsonBody); - } else { - const src = imageUrl + '?d=' + utils.base64URLEncode(jsonBody); - createImage(src, sync ? null : onDone); - } - }; + return Promise.reject(); + }); + } - if (sync) { - send(); + if (usePost) { + return doPostRequest(true).catch(() => {}); } else { - return new Promise(resolve => { - send(resolve); - }); + const src = imageUrl + '?d=' + utils.base64URLEncode(jsonBody); + createImage(src); + return Promise.resolve(); + // We do not specify an onload handler for the image because we don't want the client to wait around + // for the image to load - it won't provide a server response, there's nothing to be done. } } - sender.sendEvents = function(events, sync) { - if (!platform.newHttpRequest) { - return Promise.resolve(); - } - // Workaround for non-support of sync XHR in some browsers - https://github.com/launchdarkly/js-client/issues/147 - if (sync && !(platform.httpAllowsSync && platform.httpAllowsSync())) { + sender.sendEvents = function(events) { + if (!platform.httpRequest) { return Promise.resolve(); } const canPost = platform.httpAllowsPost(); - const finalSync = sync === undefined ? false : sync; let chunks; if (canPost) { // no need to break up events into chunks if we can send a POST @@ -90,9 +83,9 @@ export default function EventSender(platform, eventsUrl, environmentId, imageCre } const results = []; for (let i = 0; i < chunks.length; i++) { - results.push(sendChunk(chunks[i], canPost, finalSync)); + results.push(sendChunk(chunks[i], canPost)); } - return sync ? Promise.resolve() : Promise.all(results); + return Promise.all(results); }; return sender; diff --git a/packages/ldclient-js-common/src/Identity.js b/packages/ldclient-js-common/src/Identity.js index 7eae6f88..e5b38bd3 100644 --- a/packages/ldclient-js-common/src/Identity.js +++ b/packages/ldclient-js-common/src/Identity.js @@ -1,20 +1,14 @@ import * as utils from './utils'; -function sanitizeUser(u) { - const sane = utils.clone(u); - if (sane.key) { - sane.key = sane.key.toString(); - } - return sane; -} - export default function Identity(initialUser, onChange) { const ident = {}; let user; ident.setUser = function(u) { - user = sanitizeUser(u); - onChange && onChange(utils.clone(user)); + user = utils.sanitizeUser(u); + if (user && onChange) { + onChange(utils.clone(user)); + } }; ident.getUser = function() { diff --git a/packages/ldclient-js-common/src/Requestor.js b/packages/ldclient-js-common/src/Requestor.js index 3a6a15dc..a4b8c6a7 100644 --- a/packages/ldclient-js-common/src/Requestor.js +++ b/packages/ldclient-js-common/src/Requestor.js @@ -1,14 +1,15 @@ import * as utils from './utils'; import * as errors from './errors'; import * as messages from './messages'; +import promiseCoalescer from './promiseCoalescer'; const json = 'application/json'; -function getResponseError(xhr) { - if (xhr.status === 404) { +function getResponseError(result) { + if (result.status === 404) { return new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); } else { - return xhr.statusText; + return new errors.LDFlagFetchError(messages.errorFetchingFlags(result.statusText || String(result.status))); } } @@ -17,65 +18,66 @@ export default function Requestor(platform, options, environment, logger) { const useReport = options.useReport; const withReasons = options.evaluationReasons; const sendLDHeaders = options.sendLDHeaders; - let flagSettingsRequest; - let lastFlagSettingsCallback; const requestor = {}; - function fetchJSON(endpoint, body, callback) { - if (!platform.newHttpRequest) { - utils.onNextTick(() => { - callback(new errors.LDFlagFetchError(messages.httpUnavailable())); + const activeRequests = {}; // map of URLs to promiseCoalescers + + function fetchJSON(endpoint, body) { + if (!platform.httpRequest) { + return new Promise((resolve, reject) => { + reject(new errors.LDFlagFetchError(messages.httpUnavailable())); }); - return; } - const xhr = platform.newHttpRequest(); - let data = undefined; - - xhr.addEventListener('load', () => { - if ( - xhr.status === 200 && - xhr.getResponseHeader('Content-type') && - xhr.getResponseHeader('Content-Type').lastIndexOf(json) === 0 - ) { - callback(null, JSON.parse(xhr.responseText)); - } else { - callback(getResponseError(xhr)); - } - }); - - xhr.addEventListener('error', () => { - callback(getResponseError(xhr)); - }); - + const method = body ? 'REPORT' : 'GET'; + const headers = sendLDHeaders ? utils.getLDHeaders(platform) : {}; if (body) { - xhr.open('REPORT', endpoint); - xhr.setRequestHeader('Content-Type', 'application/json'); - data = JSON.stringify(body); - } else { - xhr.open('GET', endpoint); + headers['Content-Type'] = 'application/json'; } - if (sendLDHeaders) { - utils.addLDHeaders(xhr, platform); + let coalescer = activeRequests[endpoint]; + if (!coalescer) { + coalescer = promiseCoalescer(() => { + // this will be called once there are no more active requests for the same endpoint + delete activeRequests[endpoint]; + }); + activeRequests[endpoint] = coalescer; } - xhr.send(data); - - return xhr; + const req = platform.httpRequest(method, endpoint, headers, body); + const p = req.promise.then( + result => { + if ( + result.status === 200 && + result.header('content-type') && + result.header('content-type').lastIndexOf(json) === 0 + ) { + return JSON.parse(result.body); + } else { + return Promise.reject(getResponseError(result)); + } + }, + e => Promise.reject(new errors.LDFlagFetchError(messages.networkError(e))) + ); + coalescer.addPromise(p, () => { + // this will be called if another request for the same endpoint supersedes this one + req.cancel && req.cancel(); + }); + return coalescer.resultPromise; } - requestor.fetchFlagSettings = function(user, hash, callback) { + // Returns a Promise which will resolve with the parsed JSON response, or will be + // rejected if the request failed. + requestor.fetchFlagSettings = function(user, hash) { let data; let endpoint; let query = ''; let body; - let cb; if (useReport) { endpoint = [baseUrl, '/sdk/evalx/', environment, '/user'].join(''); - body = user; + body = JSON.stringify(user); } else { data = utils.base64URLEncode(JSON.stringify(user)); endpoint = [baseUrl, '/sdk/evalx/', environment, '/users/', data].join(''); @@ -89,33 +91,14 @@ export default function Requestor(platform, options, environment, logger) { endpoint = endpoint + (query ? '?' : '') + query; logger.debug(messages.debugPolling(endpoint)); - const wrappedCallback = (function(currentCallback) { - return function(error, result) { - currentCallback(error, result); - flagSettingsRequest = null; - lastFlagSettingsCallback = null; - }; - })(callback); - - if (flagSettingsRequest) { - flagSettingsRequest.abort(); - cb = (function(prevCallback) { - return function() { - prevCallback && prevCallback.apply(null, arguments); - wrappedCallback.apply(null, arguments); - }; - })(lastFlagSettingsCallback); - } else { - cb = wrappedCallback; - } - - lastFlagSettingsCallback = cb; - flagSettingsRequest = fetchJSON(endpoint, body, cb); + return fetchJSON(endpoint, body); }; - requestor.fetchGoals = function(callback) { + // Returns a Promise which will resolve with the parsed JSON response, or will be + // rejected if the request failed. + requestor.fetchGoals = function() { const endpoint = [baseUrl, '/sdk/goals/', environment].join(''); - fetchJSON(endpoint, null, callback); + return fetchJSON(endpoint, null); }; return requestor; diff --git a/packages/ldclient-js-common/src/Store.js b/packages/ldclient-js-common/src/Store.js index 14ad2ee7..e4f25f2d 100644 --- a/packages/ldclient-js-common/src/Store.js +++ b/packages/ldclient-js-common/src/Store.js @@ -2,10 +2,10 @@ import * as messages from './messages'; import * as utils from './utils'; // The localStorageProvider is provided by the platform object. It should have the following -// *asynchronous* methods: -// - get(key, callback): Gets the string value, if any, for the given key; calls callback(error, value) -// - set(key, value, callback): Stores a string value for the given key; calls callback(error) -// - remove(key, callback): Removes the given key; calls callback(error) +// methods, each of which should return a Promise: +// - get(key): Gets the string value, if any, for the given key +// - set(key, value): Stores a string value for the given key +// - remove(key): Removes the given key export default function Store(localStorageProvider, environment, hash, ident, logger) { const store = {}; @@ -18,15 +18,14 @@ export default function Store(localStorageProvider, environment, hash, ident, lo return 'ld:' + environment + ':' + key; } - store.loadFlags = function(callback) { - localStorageProvider.get(getFlagsKey(), (err, dataStr) => { - if (err) { - logger.warn(messages.localStorageUnavailable()); - callback && callback(err, null); - } else { + // Returns a Promise which will be resolved with a parsed JSON value if a stored value was available, + // resolved with null if there was no value, or rejected if storage was not available. + store.loadFlags = () => + localStorageProvider + .get(getFlagsKey()) + .then(dataStr => { if (dataStr === null || dataStr === undefined) { - callback && callback(null, null); - return; + return null; } try { let data = JSON.parse(dataStr); @@ -38,34 +37,33 @@ export default function Store(localStorageProvider, environment, hash, ident, lo delete data['$schema']; } } - callback && callback(null, data); + return data; } catch (ex) { - store.clearFlags(() => { - callback && callback(ex, null); - }); + return store.clearFlags().then(() => Promise.reject(ex)); } - } - }); - }; + }) + .catch(err => { + logger.warn(messages.localStorageUnavailable()); + return Promise.reject(err); + }); - store.saveFlags = function(flags, callback) { + // Returns a Promise which will be resolved with no value if successful, or rejected if storage + // was not available. + store.saveFlags = flags => { const data = utils.extend({}, flags, { $schema: 1 }); - localStorageProvider.set(getFlagsKey(), JSON.stringify(data), err => { - if (err) { - logger.warn(messages.localStorageUnavailable()); - } - callback && callback(err); + return localStorageProvider.set(getFlagsKey(), JSON.stringify(data)).catch(err => { + logger.warn(messages.localStorageUnavailable()); + return Promise.reject(err); }); }; - store.clearFlags = function(callback) { - localStorageProvider.clear(getFlagsKey(), err => { - if (err) { - logger.warn(messages.localStorageUnavailable()); - } - callback && callback(err); + // Returns a Promise which will be resolved with no value if successful, or rejected if storage + // was not available. + store.clearFlags = () => + localStorageProvider.clear(getFlagsKey()).catch(err => { + logger.warn(messages.localStorageUnavailable()); + return Promise.reject(err); }); - }; return store; } diff --git a/packages/ldclient-js-common/src/UserValidator.js b/packages/ldclient-js-common/src/UserValidator.js new file mode 100644 index 00000000..e592a284 --- /dev/null +++ b/packages/ldclient-js-common/src/UserValidator.js @@ -0,0 +1,65 @@ +import uuidv1 from 'uuid/v1'; + +import * as errors from './errors'; +import * as messages from './messages'; +import * as utils from './utils'; + +// Transforms the user object if necessary to make sure it has a valid key. +// 1. If a key is present, but is not a string, change it to a string. +// 2. If no key is present, and "anonymous" is true, use a UUID as a key. This is cached in local +// storage if possible. +// 3. If there is no key (or no user object), return an error. + +const ldUserIdKey = 'ld:$anonUserId'; + +export default function UserValidator(localStorageProvider, logger) { + function getCachedUserId() { + if (localStorageProvider) { + return localStorageProvider.get(ldUserIdKey).catch(() => null); + // Not logging errors here, because if local storage fails for the get, it will presumably fail for the set, + // so we will end up logging an error in setCachedUserId anyway. + } + return Promise.resolve(null); + } + + function setCachedUserId(id) { + if (localStorageProvider) { + return localStorageProvider.set(ldUserIdKey, id).catch(() => { + logger.warn(messages.localStorageUnavailableForUserId()); + }); + } + return Promise.resolve(); + } + + const ret = {}; + + // Validates the user, returning a Promise that resolves to the validated user, or rejects if there is an error. + ret.validateUser = user => { + if (!user) { + return Promise.reject(new errors.LDInvalidUserError(messages.userNotSpecified())); + } + + const userOut = utils.clone(user); + if (userOut.key !== null && userOut.key !== undefined) { + userOut.key = userOut.key.toString(); + return Promise.resolve(userOut); + return; + } + if (userOut.anonymous) { + return getCachedUserId().then(cachedId => { + if (cachedId) { + userOut.key = cachedId; + return userOut; + } else { + const id = uuidv1(); + userOut.key = id; + return setCachedUserId(id).then(() => userOut); + } + }); + } else { + return Promise.reject(new errors.LDInvalidUserError(messages.invalidUser())); + } + }; + + return ret; +} diff --git a/packages/ldclient-js-common/src/__tests__/EventProcessor-test.js b/packages/ldclient-js-common/src/__tests__/EventProcessor-test.js index bf13155c..70f4924e 100644 --- a/packages/ldclient-js-common/src/__tests__/EventProcessor-test.js +++ b/packages/ldclient-js-common/src/__tests__/EventProcessor-test.js @@ -67,66 +67,35 @@ describe('EventProcessor', () => { expect(e.kind).toEqual('summary'); } - it('should flush asynchronously', () => { - const processor = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); - const event = { kind: 'identify', key: user.key }; - - processor.enqueue(event); - processor.enqueue(event); - processor.enqueue(event); - processor.enqueue(event); - processor.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].sync).toEqual(false); - }); - - it('should flush synchronously', () => { - const processor = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); - const user = { key: 'foo' }; - const event = { kind: 'identify', key: user.key }; - - processor.enqueue(event); - processor.enqueue(event); - processor.enqueue(event); - processor.enqueue(event); - processor.flush(true); - - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].sync).toEqual(true); - }); - - it('should enqueue identify event', done => { + it('should enqueue identify event', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].events).toEqual([event]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + expect(mockEventSender.calls[0].events).toEqual([event]); }); - it('filters user in identify event', done => { + it('filters user in identify event', async () => { const config = Object.assign({}, defaultConfig, { allAttributesPrivate: true }); const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].events).toEqual([ - { - kind: 'identify', - creationDate: event.creationDate, - key: user.key, - user: filteredUser, - }, - ]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + expect(mockEventSender.calls[0].events).toEqual([ + { + kind: 'identify', + creationDate: event.creationDate, + key: user.key, + user: filteredUser, + }, + ]); }); - it('queues individual feature event', done => { + it('queues individual feature event', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const event = { kind: 'feature', @@ -136,17 +105,16 @@ describe('EventProcessor', () => { trackEvents: true, }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false); - checkSummaryEvent(output[1]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false); + checkSummaryEvent(output[1]); }); - it('can include inline user in feature event', done => { + it('can include inline user in feature event', async () => { const config = Object.assign({}, defaultConfig, { inlineUsersInEvents: true }); const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); const event = { @@ -157,17 +125,16 @@ describe('EventProcessor', () => { trackEvents: true, }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, user); + checkSummaryEvent(output[1]); }); - it('can include reason in feature event', done => { + it('can include reason in feature event', async () => { const config = Object.assign({}, defaultConfig, { inlineUsersInEvents: true }); const reason = { kind: 'FALLTHROUGH' }; const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); @@ -180,17 +147,16 @@ describe('EventProcessor', () => { reason: reason, }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, user); + checkSummaryEvent(output[1]); }); - it('filters user in feature event', done => { + it('filters user in feature event', async () => { const config = Object.assign({}, defaultConfig, { allAttributesPrivate: true, inlineUsersInEvents: true }); const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); const event = { @@ -201,17 +167,16 @@ describe('EventProcessor', () => { trackEvents: true, }; ep.enqueue(event); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, filteredUser); - checkSummaryEvent(output[1]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, filteredUser); + checkSummaryEvent(output[1]); }); - it('sets event kind to debug if event is temporarily in debug mode', done => { + it('sets event kind to debug if event is temporarily in debug mode', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const futureTime = new Date().getTime() + 1000000; const e = { @@ -226,17 +191,16 @@ describe('EventProcessor', () => { debugEventsUntilDate: futureTime, }; ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], e, true, user); - checkSummaryEvent(output[1]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], e, true, user); + checkSummaryEvent(output[1]); }); - it('can both track and debug an event', done => { + it('can both track and debug an event', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const futureTime = new Date().getTime() + 1000000; const e = { @@ -251,18 +215,17 @@ describe('EventProcessor', () => { debugEventsUntilDate: futureTime, }; ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(3); - checkFeatureEvent(output[0], e, false); - checkFeatureEvent(output[1], e, true, user); - checkSummaryEvent(output[2]); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(3); + checkFeatureEvent(output[0], e, false); + checkFeatureEvent(output[1], e, true, user); + checkSummaryEvent(output[2]); }); - it('expires debug mode based on client time if client time is later than server time', done => { + it('expires debug mode based on client time if client time is later than server time', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); // Pick a server time that is somewhat behind the client time @@ -271,35 +234,33 @@ describe('EventProcessor', () => { // Send and flush an event we don't care about, just to set the last server time ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); - ep.flush().then(() => { - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the server time, but in the past compared to the client. - const debugUntil = serverTime + 1000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: false, - debugEventsUntilDate: debugUntil, - }; - ep.enqueue(e); - - // Should get a summary event only, not a full feature event - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(2); - const output = mockEventSender.calls[1].events; - expect(output.length).toEqual(1); - checkSummaryEvent(output[0]); - done(); - }); - }); + await ep.flush(); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + const debugUntil = serverTime + 1000; + const e = { + kind: 'feature', + creationDate: 1000, + user: user, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: false, + debugEventsUntilDate: debugUntil, + }; + ep.enqueue(e); + + // Should get a summary event only, not a full feature event + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(2); + const output = mockEventSender.calls[1].events; + expect(output.length).toEqual(1); + checkSummaryEvent(output[0]); }); - it('expires debug mode based on server time if server time is later than client time', done => { + it('expires debug mode based on server time if server time is later than client time', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); // Pick a server time that is somewhat ahead of the client time @@ -308,35 +269,33 @@ describe('EventProcessor', () => { // Send and flush an event we don't care about, just to set the last server time ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); - ep.flush().then(() => { - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the client time, but in the past compared to the server. - const debugUntil = serverTime - 1000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: false, - debugEventsUntilDate: debugUntil, - }; - ep.enqueue(e); - - // Should get a summary event only, not a full feature event - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(2); - const output = mockEventSender.calls[1].events; - expect(output.length).toEqual(1); - checkSummaryEvent(output[0]); - done(); - }); - }); + await ep.flush(); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + const debugUntil = serverTime - 1000; + const e = { + kind: 'feature', + creationDate: 1000, + user: user, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: false, + debugEventsUntilDate: debugUntil, + }; + ep.enqueue(e); + + // Should get a summary event only, not a full feature event + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(2); + const output = mockEventSender.calls[1].events; + expect(output.length).toEqual(1); + checkSummaryEvent(output[0]); }); - it('summarizes nontracked events', done => { + it('summarizes nontracked events', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); function makeEvent(key, date, version, variation, value, defaultVal) { return { @@ -355,29 +314,28 @@ describe('EventProcessor', () => { const e2 = makeEvent('flagkey2', 2000, 22, 1, 'value2', 'default2'); ep.enqueue(e1); ep.enqueue(e2); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - const se = output[0]; - checkSummaryEvent(se); - expect(se.startDate).toEqual(1000); - expect(se.endDate).toEqual(2000); - expect(se.features).toEqual({ - flagkey1: { - default: 'default1', - counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }], - }, - flagkey2: { - default: 'default2', - counters: [{ version: 22, variation: 1, value: 'value2', count: 1 }], - }, - }); - done(); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + const se = output[0]; + checkSummaryEvent(se); + expect(se.startDate).toEqual(1000); + expect(se.endDate).toEqual(2000); + expect(se.features).toEqual({ + flagkey1: { + default: 'default1', + counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }], + }, + flagkey2: { + default: 'default2', + counters: [{ version: 22, variation: 1, value: 'value2', count: 1 }], + }, }); }); - it('queues custom event', done => { + it('queues custom event', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const e = { kind: 'custom', @@ -387,16 +345,15 @@ describe('EventProcessor', () => { data: { thing: 'stuff' }, }; ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e); }); - it('can include inline user in custom event', done => { + it('can include inline user in custom event', async () => { const config = Object.assign({}, defaultConfig, { inlineUsersInEvents: true }); const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); const e = { @@ -407,16 +364,15 @@ describe('EventProcessor', () => { data: { thing: 'stuff' }, }; ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, user); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e, user); }); - it('filters user in custom event', done => { + it('filters user in custom event', async () => { const config = Object.assign({}, defaultConfig, { allAttributesPrivate: true, inlineUsersInEvents: true }); const ep = EventProcessor(platform, config, envId, logger, null, mockEventSender); const e = { @@ -427,57 +383,52 @@ describe('EventProcessor', () => { data: { thing: 'stuff' }, }; ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, filteredUser); - done(); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e, filteredUser); }); - it('sends nothing if there are no events to flush', done => { + it('sends nothing if there are no events to flush', async () => { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(0); - done(); - }); + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(0); }); - function verifyUnrecoverableHttpError(done, status) { + async function verifyUnrecoverableHttpError(status) { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const e = { kind: 'identify', creationDate: 1000, user: user }; ep.enqueue(e); mockEventSender.status = status; - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); // still the one from our first flush - done(); - }); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + ep.enqueue(e); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); // still the one from our first flush } - function verifyRecoverableHttpError(done, status) { + async function verifyRecoverableHttpError(status) { const ep = EventProcessor(platform, defaultConfig, envId, logger, null, mockEventSender); const e = { kind: 'identify', creationDate: 1000, user: user }; ep.enqueue(e); mockEventSender.status = status; - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(1); - ep.enqueue(e); - ep.flush().then(() => { - expect(mockEventSender.calls.length).toEqual(2); - done(); - }); - }); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + ep.enqueue(e); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(2); } - it('stops sending events after a 401 error', done => verifyUnrecoverableHttpError(done, 401)); - it('stops sending events after a 403 error', done => verifyUnrecoverableHttpError(done, 403)); - it('stops sending events after a 404 error', done => verifyUnrecoverableHttpError(done, 404)); - it('continues sending events after a 408 error', done => verifyRecoverableHttpError(done, 408)); - it('continues sending events after a 429 error', done => verifyRecoverableHttpError(done, 429)); - it('continues sending events after a 500 error', done => verifyRecoverableHttpError(done, 500)); + it('stops sending events after a 401 error', () => verifyUnrecoverableHttpError(401)); + it('stops sending events after a 403 error', () => verifyUnrecoverableHttpError(403)); + it('stops sending events after a 404 error', () => verifyUnrecoverableHttpError(404)); + it('continues sending events after a 408 error', () => verifyRecoverableHttpError(408)); + it('continues sending events after a 429 error', () => verifyRecoverableHttpError(429)); + it('continues sending events after a 500 error', () => verifyRecoverableHttpError(500)); }); diff --git a/packages/ldclient-js-common/src/__tests__/EventSender-test.js b/packages/ldclient-js-common/src/__tests__/EventSender-test.js index 23939e9c..df7ccca0 100644 --- a/packages/ldclient-js-common/src/__tests__/EventSender-test.js +++ b/packages/ldclient-js-common/src/__tests__/EventSender-test.js @@ -1,41 +1,32 @@ import * as base64 from 'base64-js'; -import sinon from 'sinon'; import * as stubPlatform from './stubPlatform'; +import { errorResponse, makeDefaultServer } from './testUtils'; import EventSender from '../EventSender'; import * as utils from '../utils'; describe('EventSender', () => { const platform = stubPlatform.defaults(); const platformWithoutCors = Object.assign({}, platform, { httpAllowsPost: () => false }); - let sandbox; - let xhr; - let requests = []; + let server; const eventsUrl = '/fake-url'; const envId = 'env'; beforeEach(() => { - sandbox = sinon.createSandbox(); - requests = []; - xhr = sinon.useFakeXMLHttpRequest(); - xhr.onCreate = function(xhr) { - requests.push(xhr); - }; + server = makeDefaultServer(); }); afterEach(() => { - sandbox.restore(); - xhr.restore(); + server.restore(); }); function lastRequest() { - return requests[requests.length - 1]; + return server.requests[server.requests.length - 1]; } function fakeImageCreator() { - const ret = function(url, onDone) { + const ret = function(url) { ret.urls.push(url); - ret.onDone = onDone; }; ret.urls = []; return ret; @@ -61,21 +52,21 @@ describe('EventSender', () => { } describe('using image endpoint when CORS is not available', () => { - it('should encode events in a single chunk if they fit', () => { + it('should encode events in a single chunk if they fit', async () => { const imageCreator = fakeImageCreator(); const sender = EventSender(platformWithoutCors, eventsUrl, envId, imageCreator); const event1 = { kind: 'identify', key: 'userKey1' }; const event2 = { kind: 'identify', key: 'userKey2' }; const events = [event1, event2]; - sender.sendEvents(events, false); + await sender.sendEvents(events, false); const urls = imageCreator.urls; expect(urls.length).toEqual(1); expect(decodeOutputFromUrl(urls[0])).toEqual(events); }); - it('should send events in multiple chunks if necessary', () => { + it('should send events in multiple chunks if necessary', async () => { const imageCreator = fakeImageCreator(); const sender = EventSender(platformWithoutCors, eventsUrl, envId, imageCreator); const events = []; @@ -83,7 +74,7 @@ describe('EventSender', () => { events.push({ kind: 'identify', key: 'thisIsALongUserKey' + i }); } - sender.sendEvents(events, false); + await sender.sendEvents(events, false); const urls = imageCreator.urls; expect(urls.length).toEqual(3); @@ -91,115 +82,90 @@ describe('EventSender', () => { expect(decodeOutputFromUrl(urls[1])).toEqual(events.slice(31, 62)); expect(decodeOutputFromUrl(urls[2])).toEqual(events.slice(62, 80)); }); - - it('should set a completion handler', () => { - const imageCreator = fakeImageCreator(); - const sender = EventSender(platformWithoutCors, eventsUrl, envId, imageCreator); - const event1 = { kind: 'identify', key: 'userKey1' }; - - sender.sendEvents([event1], false); - - expect(imageCreator.onDone).toBeDefined(); - }); }); describe('using POST when CORS is available', () => { - it('should send asynchronously', () => { - const sender = EventSender(platform, eventsUrl, envId); - const event = { kind: 'identify', key: 'userKey' }; - sender.sendEvents([event], false); - requests[0].respond(); - expect(requests.length).toEqual(1); - expect(requests[0].async).toEqual(true); - expect(JSON.parse(requests[0].requestBody)).toEqual([event]); - }); - - it('should send synchronously', () => { - const sender = EventSender(platform, eventsUrl, envId); - const event = { kind: 'identify', key: 'userKey' }; - sender.sendEvents([event], true); - lastRequest().respond(); - expect(lastRequest().async).toEqual(false); - }); - - it('should skip synchronous request if not supported', () => { - const noSyncPlatform = stubPlatform.defaults(); - noSyncPlatform.httpAllowsSync = () => false; - const sender = EventSender(noSyncPlatform, eventsUrl, envId); - const event = { kind: 'identify', key: 'userKey' }; - sender.sendEvents([event], true); - expect(requests.length).toEqual(0); - }); - - it('should send all events in request body', () => { + it('should send all events in request body', async () => { const sender = EventSender(platform, eventsUrl, envId); const events = []; for (let i = 0; i < 80; i++) { events.push({ kind: 'identify', key: 'thisIsALongUserKey' + i }); } - sender.sendEvents(events, false); - lastRequest().respond(); + await sender.sendEvents(events, false); const r = lastRequest(); expect(r.url).toEqual(eventsUrl + '/events/bulk/' + envId); expect(r.method).toEqual('POST'); expect(JSON.parse(r.requestBody)).toEqual(events); }); - it('should send custom user-agent header', () => { + it('should send custom user-agent header', async () => { const sender = EventSender(platform, eventsUrl, envId); const event = { kind: 'identify', key: 'userKey' }; - sender.sendEvents([event], false); - lastRequest().respond(); + await sender.sendEvents([event], false); expect(lastRequest().requestHeaders['X-LaunchDarkly-User-Agent']).toEqual(utils.getLDUserAgentString(platform)); }); const retryableStatuses = [400, 408, 429, 500, 503]; for (const i in retryableStatuses) { const status = retryableStatuses[i]; - it('should retry on error ' + status, () => { + it('should retry on error ' + status, async () => { + let n = 0; + server.respondWith(req => { + n++; + req.respond(n >= 2 ? 200 : status); + }); const sender = EventSender(platform, eventsUrl, envId); const event = { kind: 'false', key: 'userKey' }; - sender.sendEvents([event], false); - requests[0].respond(status); - expect(requests.length).toEqual(2); - expect(JSON.parse(requests[1].requestBody)).toEqual([event]); + await sender.sendEvents([event], false); + expect(server.requests.length).toEqual(2); + expect(JSON.parse(server.requests[1].requestBody)).toEqual([event]); }); } - it('should not retry more than once', () => { + it('should not retry more than once', async () => { + let n = 0; + server.respondWith(req => { + n++; + req.respond(n >= 3 ? 200 : 503); + }); const sender = EventSender(platform, eventsUrl, envId); const event = { kind: 'false', key: 'userKey' }; - sender.sendEvents([event], false); - requests[0].respond(503); - expect(requests.length).toEqual(2); - requests[1].respond(503); - expect(requests.length).toEqual(2); + await sender.sendEvents([event], false); + expect(server.requests.length).toEqual(2); }); - it('should not retry on error 401', () => { + it('should not retry on error 401', async () => { + server.respondWith(errorResponse(401)); const sender = EventSender(platform, eventsUrl, envId); const event = { kind: 'false', key: 'userKey' }; - sender.sendEvents([event], false); - requests[0].respond(401); - expect(requests.length).toEqual(1); + await sender.sendEvents([event], false); + expect(server.requests.length).toEqual(1); }); - it('should retry on I/O error', () => { + it('should retry on I/O error', async () => { + let n = 0; + server.respondWith(req => { + n++; + if (n >= 2) { + req.respond(200); + } else { + req.error(); + } + }); const sender = EventSender(platform, eventsUrl, envId); const event = { kind: 'false', key: 'userKey' }; - sender.sendEvents([event], false); - requests[0].error(); - expect(requests.length).toEqual(2); - expect(JSON.parse(requests[1].requestBody)).toEqual([event]); + await sender.sendEvents([event], false); + expect(server.requests.length).toEqual(2); + expect(JSON.parse(server.requests[1].requestBody)).toEqual([event]); }); }); describe('When HTTP requests are not available at all', () => { - it('should silently discard events', () => { + it('should silently discard events', async () => { const sender = EventSender(stubPlatform.withoutHttp(), eventsUrl, envId); const event = { kind: 'false', key: 'userKey' }; - sender.sendEvents([event], false); - expect(requests.length).toEqual(0); + await sender.sendEvents([event], false); + expect(server.requests.length).toEqual(0); }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/LDClient-events-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-events-test.js index f0ce5b32..c041e45d 100644 --- a/packages/ldclient-js-common/src/__tests__/LDClient-events-test.js +++ b/packages/ldclient-js-common/src/__tests__/LDClient-events-test.js @@ -1,29 +1,21 @@ -import sinon from 'sinon'; - import * as stubPlatform from './stubPlatform'; -import * as utils from '../utils'; +import { jsonResponse, makeBootstrap, makeDefaultServer, numericUser, stringifiedNumericUser } from './testUtils'; describe('LDClient', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const user = { key: 'user' }; const fakeUrl = 'http://fake'; let platform; - let xhr; - let requests = []; + let server; beforeEach(() => { - xhr = sinon.useFakeXMLHttpRequest(); - xhr.onCreate = function(req) { - requests.push(req); - }; - + server = makeDefaultServer(); platform = stubPlatform.defaults(); platform.testing.setCurrentUrl(fakeUrl); }); afterEach(() => { - requests = []; - xhr.restore(); + server.restore(); }); describe('event generation', () => { @@ -54,335 +46,261 @@ describe('LDClient', () => { expect(e.debugEventsUntilDate).toEqual(debugEventsUntilDate); } - it('sends an identify event at startup', done => { + it('sends an identify event at startup', async () => { const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: {} }); - - client.on('ready', () => { - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], user); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + await client.waitForInitialization(); - done(); - }); + expect(ep.events.length).toEqual(1); + expectIdentifyEvent(ep.events[0], user); }); - it('sends an identify event when identify() is called', done => { + it('stringifies user attributes in the identify event at startup', async () => { + // This just verifies that the event is being sent with the sanitized user, not the user that was passed in const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{}']); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: {} }); - const user1 = { key: 'user1' }; + const client = platform.testing.makeClient(envName, numericUser, { eventProcessor: ep }); + await client.waitForInitialization(); - client.on('ready', () => { - expect(ep.events.length).toEqual(1); - client.identify(user1).then(() => { - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[1], user1); - done(); - }); - utils.onNextTick(() => server.respond()); - }); + expect(ep.events.length).toEqual(1); + expectIdentifyEvent(ep.events[0], stringifiedNumericUser); }); - it('does not send an identify event if doNotTrack is set', done => { - platform.testing.setDoNotTrack(true); - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{}']); + it('sends an identify event when identify() is called', async () => { const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { - eventProcessor: ep, - bootstrap: {}, - fetchGoals: false, - }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); const user1 = { key: 'user1' }; + await client.waitForInitialization(); - client.on('ready', () => { - client.identify(user1).then(() => { - expect(ep.events.length).toEqual(0); - done(); - }); - utils.onNextTick(() => server.respond()); - }); + expect(ep.events.length).toEqual(1); + await client.identify(user1); + server.respond(); + + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[1], user1); }); - it('sends a feature event for variation()', done => { + it('stringifies user attributes in the identify event when identify() is called', async () => { + // This just verifies that the event is being sent with the sanitized user, not the user that was passed in const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([ - 200, - { 'Content-Type': 'application/json' }, - '{"foo":{"value":"a","variation":1,"version":2,"flagVersion":2000}}', - ]); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + await client.waitForInitialization(); - client.on('ready', () => { - client.variation('foo', 'x'); + expect(ep.events.length).toEqual(1); + await client.identify(numericUser); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[1], stringifiedNumericUser); + }); - done(); - }); + it('does not send an identify event if doNotTrack is set', async () => { + platform.testing.setDoNotTrack(true); + const ep = stubEventProcessor(); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + const user1 = { key: 'user1' }; - server.respond(); + await client.waitForInitialization(); + await client.identify(user1); + + expect(ep.events.length).toEqual(0); }); - it('sends a feature event for variationDetail()', done => { + it('sends a feature event for variation()', async () => { + const initFlags = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }); const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([ - 200, - { 'Content-Type': 'application/json' }, - '{"foo":{"value":"a","variation":1,"version":2,"flagVersion":2000,"reason":{"kind":"OFF"}}}', - ]); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); + + await client.waitForInitialization(); - client.on('ready', () => { - client.variationDetail('foo', 'x'); + client.variation('foo', 'x'); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); - expect(ep.events[1].reason).toEqual({ kind: 'OFF' }); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); + }); - done(); + it('sends a feature event for variationDetail()', async () => { + const initFlags = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' } }, }); + const ep = stubEventProcessor(); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - server.respond(); + await client.waitForInitialization(); + client.variationDetail('foo', 'x'); + + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); + expect(ep.events[1].reason).toEqual({ kind: 'OFF' }); }); - it('sends a feature event on receiving a new flag value', done => { + it('sends a feature event on receiving a new flag value', async () => { const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - server.respondWith([200, { 'Content-Type': 'application/json' }, JSON.stringify(oldFlags)]); + server.respondWith(jsonResponse(oldFlags)); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - client.on('ready', () => { - const user1 = { key: 'user1' }; - server.respondWith([200, { 'Content-Type': 'application/json' }, JSON.stringify(newFlags)]); + await client.waitForInitialization(); - client.identify(user1, null, () => { - expect(ep.events.length).toEqual(3); - expectIdentifyEvent(ep.events[0], user); - expectIdentifyEvent(ep.events[1], user1); - expectFeatureEvent(ep.events[2], 'foo', 'b', 2, 2001); - - done(); - }); - - utils.onNextTick(() => server.respond()); - }); + const user1 = { key: 'user1' }; + server.respondWith(jsonResponse(newFlags)); + await client.identify(user1); - utils.onNextTick(() => server.respond()); + expect(ep.events.length).toEqual(3); + expectIdentifyEvent(ep.events[0], user); + expectIdentifyEvent(ep.events[1], user1); + expectFeatureEvent(ep.events[2], 'foo', 'b', 2, 2001); }); - it('does not send a feature event for a new flag value if sendEventsOnlyForVariation is set', done => { + it('does not send a feature event for a new flag value if sendEventsOnlyForVariation is set', async () => { const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - server.respondWith([200, { 'Content-Type': 'application/json' }, JSON.stringify(oldFlags)]); + server.respondWith(jsonResponse(oldFlags)); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, sendEventsOnlyForVariation: true, }); - client.on('ready', () => { - const user1 = { key: 'user1' }; - server.respondWith([200, { 'Content-Type': 'application/json' }, JSON.stringify(newFlags)]); + await client.waitForInitialization(); - client.identify(user1, null, () => { - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectIdentifyEvent(ep.events[1], user1); - - done(); - }); - - utils.onNextTick(() => server.respond()); - }); + const user1 = { key: 'user1' }; + server.respondWith(jsonResponse(newFlags)); + await client.identify(user1); - utils.onNextTick(() => server.respond()); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectIdentifyEvent(ep.events[1], user1); }); - it('does not send a feature event for a new flag value if there is a state provider', done => { + it('does not send a feature event for a new flag value if there is a state provider', async () => { const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([ - 200, - { 'Content-Type': 'application/json' }, - '{"foo":{"value":"a","variation":1,"version":2,"flagVersion":2000}}', - ]); const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: oldFlags }); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, stateProvider: sp }); - client.on('ready', () => { - sp.emit('update', { flags: newFlags }); + await client.waitForInitialization(); - expect(client.variation('foo')).toEqual('b'); - expect(ep.events.length).toEqual(1); + sp.emit('update', { flags: newFlags }); - done(); - }); + expect(client.variation('foo')).toEqual('b'); + expect(ep.events.length).toEqual(1); }); - it('sends feature events for allFlags()', done => { + it('sends feature events for allFlags()', async () => { const ep = stubEventProcessor(); - const boots = { - foo: 'a', - bar: 'b', - $flagsState: { - foo: { variation: 1, version: 2 }, - bar: { variation: 1, version: 3 }, - }, - }; - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: boots }); - - client.on('ready', () => { - client.allFlags(); + const initFlags = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2 }, + bar: { value: 'b', variation: 1, version: 3 }, + }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - expect(ep.events.length).toEqual(3); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, null); - expectFeatureEvent(ep.events[2], 'bar', 'b', 1, 3, null); + await client.waitForInitialization(); + client.allFlags(); - done(); - }); + expect(ep.events.length).toEqual(3); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, null); + expectFeatureEvent(ep.events[2], 'bar', 'b', 1, 3, null); }); - it('does not send feature events for allFlags() if sendEventsOnlyForVariation is set', done => { + it('does not send feature events for allFlags() if sendEventsOnlyForVariation is set', async () => { const ep = stubEventProcessor(); - const boots = { - foo: 'a', - bar: 'b', - $flagsState: { - foo: { variation: 1, version: 2 }, - bar: { variation: 1, version: 3 }, - }, - }; + const initFlags = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2 }, + bar: { value: 'b', variation: 1, version: 3 }, + }); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, - bootstrap: boots, + bootstrap: initFlags, sendEventsOnlyForVariation: true, }); - client.on('ready', () => { - client.allFlags(); + await client.waitForInitialization(); + client.allFlags(); - expect(ep.events.length).toEqual(1); - expectIdentifyEvent(ep.events[0], user); - - done(); - }); + expect(ep.events.length).toEqual(1); + expectIdentifyEvent(ep.events[0], user); }); - it('uses "version" instead of "flagVersion" in event if "flagVersion" is absent', done => { + it('uses "version" instead of "flagVersion" in event if "flagVersion" is absent', async () => { const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([ - 200, - { 'Content-Type': 'application/json' }, - '{"foo":{"value":"a","variation":1,"version":2}}', - ]); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - - client.on('ready', () => { - client.variation('foo', 'x'); + const initFlags = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2 } }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, 'x'); + await client.waitForInitialization(); + client.variation('foo', 'x'); - done(); - }); - - server.respond(); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, 'x'); }); - it('omits event version if flag does not exist', done => { + it('omits event version if flag does not exist', async () => { const ep = stubEventProcessor(); - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{}']); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - client.on('ready', () => { - client.variation('foo', 'x'); - - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'x', null, undefined, 'x'); + await client.waitForInitialization(); + client.variation('foo', 'x'); - done(); - }); - - server.respond(); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'x', null, undefined, 'x'); }); - it('can get metadata for events from bootstrap object', done => { + it('can get metadata for events from bootstrap object', async () => { const ep = stubEventProcessor(); - const bootstrapData = { - foo: 'bar', - $flagsState: { - foo: { - variation: 1, - version: 2, - trackEvents: true, - debugEventsUntilDate: 1000, - }, + const initFlags = makeBootstrap({ + foo: { + value: 'bar', + variation: 1, + version: 2, + trackEvents: true, + debugEventsUntilDate: 1000, }, - }; - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: bootstrapData }); - - client.on('ready', () => { - client.variation('foo', 'x'); + }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'bar', 1, 2, 'x', true, 1000); + await client.waitForInitialization(); + client.variation('foo', 'x'); - done(); - }); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'bar', 1, 2, 'x', true, 1000); }); - it('sends an event for track()', done => { + it('sends an event for track()', async () => { const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: {} }); - const data = { thing: 'stuff' }; - client.on('ready', () => { - client.track('eventkey', data); - - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - const trackEvent = ep.events[1]; - expect(trackEvent.kind).toEqual('custom'); - expect(trackEvent.key).toEqual('eventkey'); - expect(trackEvent.user).toEqual(user); - expect(trackEvent.data).toEqual(data); - expect(trackEvent.url).toEqual(fakeUrl); - done(); - }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + const eventData = { thing: 'stuff' }; + await client.waitForInitialization(); + client.track('eventkey', eventData); + + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + const trackEvent = ep.events[1]; + expect(trackEvent.kind).toEqual('custom'); + expect(trackEvent.key).toEqual('eventkey'); + expect(trackEvent.user).toEqual(user); + expect(trackEvent.data).toEqual(eventData); + expect(trackEvent.url).toEqual(fakeUrl); }); - it('does not send an event for track() if doNotTrack is set', done => { + it('does not send an event for track() if doNotTrack is set', async () => { platform.testing.setDoNotTrack(true); const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: {} }); - const data = { thing: 'stuff' }; - client.on('ready', () => { - client.track('eventkey', data); - expect(ep.events.length).toEqual(0); - done(); - }); + const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + const eventData = { thing: 'stuff' }; + await client.waitForInitialization(); + client.track('eventkey', eventData); + expect(ep.events.length).toEqual(0); }); - it('allows stateProvider to take over sending an event', done => { + it('allows stateProvider to take over sending an event', async () => { const ep = stubEventProcessor(); const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: {} }); @@ -390,14 +308,12 @@ describe('LDClient', () => { sp.enqueueEvent = event => divertedEvents.push(event); const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, stateProvider: sp }); + await client.waitForInitialization(); - client.on('ready', () => { - client.track('eventkey'); - expect(ep.events.length).toEqual(0); - expect(divertedEvents.length).toEqual(1); - expect(divertedEvents[0].kind).toEqual('custom'); - done(); - }); + client.track('eventkey'); + expect(ep.events.length).toEqual(0); + expect(divertedEvents.length).toEqual(1); + expect(divertedEvents[0].kind).toEqual('custom'); }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/LDClient-localstorage-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-localstorage-test.js index 13e0f2c3..d17ce56e 100644 --- a/packages/ldclient-js-common/src/__tests__/LDClient-localstorage-test.js +++ b/packages/ldclient-js-common/src/__tests__/LDClient-localstorage-test.js @@ -1,6 +1,5 @@ -import sinon from 'sinon'; - import * as stubPlatform from './stubPlatform'; +import { asyncSleep, errorResponse, jsonResponse, makeDefaultServer } from './testUtils'; import * as messages from '../messages'; import * as utils from '../utils'; @@ -11,28 +10,20 @@ describe('LDClient local storage', () => { let server; beforeEach(() => { - server = sinon.fakeServer.create(); + server = makeDefaultServer(); }); afterEach(() => { server.restore(); }); - function setupFlagsResponse(flags) { - server.respondWith([200, { 'Content-Type': 'application/json' }, JSON.stringify(flags)]); - // Because the local storage operations make it hard to know how many levels of async deferral - // will be involved in the client initialization, we'll use sinon's autoRespond mode rather - // than trying to predict exactly when we should send the response. - server.autoRespond = true; - server.autoRespondAfter = 0; - } - describe('bootstrapping from local storage', () => { - it('does not try to use local storage if the platform says it is unavailable', () => { + it('does not try to use local storage if the platform says it is unavailable', async () => { const platform = stubPlatform.defaults(); platform.localStorage = null; - platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + await client.waitForInitialization(); // should see a flag request to the server right away, as if bootstrap was not specified expect(server.requests.length).toEqual(1); @@ -40,23 +31,21 @@ describe('LDClient local storage', () => { expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); }); - it('uses cached flags if available and requests flags from server after ready', done => { + it('uses cached flags if available and requests flags from server after ready', async () => { const platform = stubPlatform.defaults(); const json = '{"flag-key": 1}'; platform.testing.setLocalStorageImmediately(lsKey, json); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + await client.waitForInitialization(); - client.waitForInitialization().then(() => { - expect(client.variation('flag-key')).toEqual(1); - expect(server.requests.length).toEqual(1); - done(); - }); + expect(client.variation('flag-key')).toEqual(1); + expect(server.requests.length).toEqual(1); }); - it('starts with empty flags and requests them from server if there are no cached flags', done => { + it('starts with empty flags and requests them from server if there are no cached flags', async () => { const platform = stubPlatform.defaults(); - setupFlagsResponse({ 'flag-key': { value: 1 } }); + server.respondWith(jsonResponse({ 'flag-key': { value: 1 } })); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); @@ -64,66 +53,52 @@ describe('LDClient local storage', () => { expect(client.variation('flag-key', 0)).toEqual(0); // verify that the flags get requested from LD - client.waitForInitialization().then(() => { - expect(client.variation('flag-key')).toEqual(1); - done(); - }); + await client.waitForInitialization(); + expect(client.variation('flag-key')).toEqual(1); }); - it('should handle localStorage.get returning an error', done => { + it('should handle localStorage.get returning an error', async () => { const platform = stubPlatform.defaults(); - platform.localStorage.get = (_, callback) => { - utils.onNextTick(() => callback(new Error())); - }; - setupFlagsResponse({ 'enable-foo': { value: true } }); + platform.localStorage.get = () => Promise.reject(new Error()); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + await client.waitForInitialization(); - client.waitForInitialization().then(() => { - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); - done(); - }); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); }); - it('should handle localStorage.set returning an error', done => { + it('should handle localStorage.set returning an error', async () => { const platform = stubPlatform.defaults(); - platform.localStorage.set = (_1, _2, callback) => { - utils.onNextTick(() => callback(new Error())); - }; - setupFlagsResponse({ 'enable-foo': { value: true } }); + platform.localStorage.set = () => Promise.reject(new Error()); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + await client.waitForInitialization(); - client.waitForInitialization().then(() => { - utils.onNextTick(() => { - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); - done(); - }); - }); + await asyncSleep(0); // allow any pending async tasks to complete + + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); }); - it('should not update cached settings if there was an error fetching flags', done => { + it('should not update cached settings if there was an error fetching flags', async () => { const platform = stubPlatform.defaults(); const json = '{"enable-foo": true}'; - server.respondWith([503, {}, '']); + server.respondWith(errorResponse(503)); platform.testing.setLocalStorageImmediately(lsKey, json); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); + await client.waitForInitialization(); - client.waitForInitialization().then(() => { - server.respond(); - utils.onNextTick(() => { - platform.localStorage.get(lsKey, (err, value) => { - expect(value).toEqual(json); - done(); - }); - }); - }); + await asyncSleep(0); // allow any pending async tasks to complete + + const value = platform.testing.getLocalStorageImmediately(lsKey); + expect(value).toEqual(json); }); - it('should use hash as localStorage key when secure mode is enabled', done => { + it('should use hash as localStorage key when secure mode is enabled', async () => { const platform = stubPlatform.defaults(); - setupFlagsResponse({ 'enable-foo': { value: true } }); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); const lsKeyHash = 'ld:UNKNOWN_ENVIRONMENT_ID:totallyLegitHash'; const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', @@ -131,38 +106,35 @@ describe('LDClient local storage', () => { fetchGoals: false, }); - client.waitForInitialization().then(() => { - const value = platform.testing.getLocalStorageImmediately(lsKeyHash); - expect(JSON.parse(value)).toEqual({ - $schema: 1, - 'enable-foo': { value: true }, - }); - done(); + await client.waitForInitialization(); + const value = platform.testing.getLocalStorageImmediately(lsKeyHash); + expect(JSON.parse(value)).toEqual({ + $schema: 1, + 'enable-foo': { value: true }, }); }); - it('should clear localStorage when user context is changed', done => { + it('should clear localStorage when user context is changed', async () => { const platform = stubPlatform.defaults(); const lsKey2 = 'ld:UNKNOWN_ENVIRONMENT_ID:' + utils.btoa('{"key":"user2"}'); const user2 = { key: 'user2' }; const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage', fetchGoals: false }); - setupFlagsResponse({ 'enable-foo': { value: true } }); - - client.waitForInitialization().then(() => { - utils.onNextTick(() => { - client.identify(user2, null, () => { - const value1 = platform.testing.getLocalStorageImmediately(lsKey); - expect(value1).not.toEqual(expect.anything()); - const value2 = platform.testing.getLocalStorageImmediately(lsKey2); - expect(JSON.parse(value2)).toEqual({ - $schema: 1, - 'enable-foo': { value: true }, - }); - done(); - }); - }); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); + + await client.waitForInitialization(); + + await asyncSleep(0); // allow any pending async tasks to complete + + await client.identify(user2); + + const value1 = platform.testing.getLocalStorageImmediately(lsKey); + expect(value1).not.toEqual(expect.anything()); + const value2 = platform.testing.getLocalStorageImmediately(lsKey2); + expect(JSON.parse(value2)).toEqual({ + $schema: 1, + 'enable-foo': { value: true }, }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js index 63496f38..2cd71d82 100644 --- a/packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js +++ b/packages/ldclient-js-common/src/__tests__/LDClient-streaming-test.js @@ -1,8 +1,8 @@ -import sinon from 'sinon'; import EventSource, { sources } from './EventSource-mock'; import * as utils from '../utils'; import * as stubPlatform from './stubPlatform'; +import { asyncSleep, jsonResponse, makeBootstrap, makeDefaultServer, promiseListener } from './testUtils'; describe('LDClient', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; @@ -10,37 +10,26 @@ describe('LDClient', () => { const user = { key: 'user' }; const encodedUser = 'eyJrZXkiOiJ1c2VyIn0'; const hash = '012345789abcde'; - let xhr; - let requests = []; let platform; + let server; beforeEach(() => { Object.defineProperty(window, 'EventSource', { value: EventSource, writable: true, }); - - xhr = sinon.useFakeXMLHttpRequest(); - xhr.onCreate = function(req) { - requests.push(req); - }; - for (const key in sources) { delete sources[key]; } + server = makeDefaultServer(); platform = stubPlatform.defaults(); }); afterEach(() => { - requests = []; - xhr.restore(); + server.restore(); }); - function getLastRequest() { - return requests[requests.length - 1]; - } - describe('streaming/event listening', () => { const streamUrl = 'https://clientstream.launchdarkly.com'; const fullStreamUrlWithUser = streamUrl + '/eval/' + envName + '/' + encodedUser; @@ -57,627 +46,517 @@ describe('LDClient', () => { expect(sources).toMatchObject({}); } - it('does not connect to the stream by default', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('does not connect to the stream by default', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - expectNoStreamIsOpen(); - done(); - }); + expectNoStreamIsOpen(); }); - it('connects to the stream if options.streaming is true', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, streaming: true }); + it('connects to the stream if options.streaming is true', async () => { + const client = platform.testing.makeClient(envName, user, { streaming: true }); + await client.waitForInitialization(); - client.on('ready', () => { - expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser); }); describe('setStreaming()', () => { - it('can connect to the stream', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('can connect to the stream', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); - }); + client.setStreaming(true); + expectStreamUrlIsOpen(fullStreamUrlWithUser); }); - it('can disconnect from the stream', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('can disconnect from the stream', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - client.setStreaming(false); - expectNoStreamIsOpen(); - done(); - }); + client.setStreaming(true); + expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.setStreaming(false); + expectNoStreamIsOpen(); }); }); describe('on("change")', () => { - it('connects to the stream if not otherwise overridden', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('connects to the stream if not otherwise overridden', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.on('change', () => {}); - client.on('ready', () => { - client.on('change', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser); }); - it('also connects if listening for a specific flag', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('also connects if listening for a specific flag', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.on('change:flagkey', () => {}); - client.on('ready', () => { - client.on('change:flagkey', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser); }); - it('does not connect if some other kind of event was specified', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('does not connect if some other kind of event was specified', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.on('error', () => {}); - client.on('ready', () => { - client.on('error', () => {}); - expectNoStreamIsOpen(); - done(); - }); + expectNoStreamIsOpen(); }); - it('does not connect if options.streaming is explicitly set to false', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, streaming: false }); + it('does not connect if options.streaming is explicitly set to false', async () => { + const client = platform.testing.makeClient(envName, user, { streaming: false }); + await client.waitForInitialization(); + client.on('change', () => {}); - client.on('ready', () => { - client.on('change', () => {}); - expectNoStreamIsOpen(); - done(); - }); + expectNoStreamIsOpen(); }); - it('does not connect if setStreaming(false) was called', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('does not connect if setStreaming(false) was called', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.setStreaming(false); + client.on('change', () => {}); - client.on('ready', () => { - client.setStreaming(false); - client.on('change', () => {}); - expectNoStreamIsOpen(); - done(); - }); + expectNoStreamIsOpen(); }); }); describe('off("change")', () => { - it('disconnects from the stream if all event listeners are removed', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('disconnects from the stream if all event listeners are removed', async () => { + const client = platform.testing.makeClient(envName, user); const listener1 = () => {}; const listener2 = () => {}; + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', listener1); - client.on('change:flagkey', listener2); - client.on('error', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - - client.off('change', listener1); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.on('change', listener1); + client.on('change:flagkey', listener2); + client.on('error', () => {}); + expectStreamUrlIsOpen(fullStreamUrlWithUser); - client.off('change:flagkey', listener2); - expectNoStreamIsOpen(); + client.off('change', listener1); + expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); - }); + client.off('change:flagkey', listener2); + expectNoStreamIsOpen(); }); - it('does not disconnect if setStreaming(true) was called, but still removes event listener', done => { + it('does not disconnect if setStreaming(true) was called, but still removes event listener', async () => { const changes1 = []; const changes2 = []; - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + const client = platform.testing.makeClient(envName, user); const listener1 = allValues => changes1.push(allValues); const listener2 = newValue => changes2.push(newValue); + await client.waitForInitialization(); - client.on('ready', () => { - client.setStreaming(true); - - client.on('change', listener1); - client.on('change:flag', listener2); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.setStreaming(true); - streamEvents().put({ - data: '{"flag":{"value":"a","version":1}}', - }); - - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a']); + client.on('change', listener1); + client.on('change:flag', listener2); + expectStreamUrlIsOpen(fullStreamUrlWithUser); - client.off('change', listener1); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + streamEvents().put({ + data: '{"flag":{"value":"a","version":1}}', + }); - streamEvents().put({ - data: '{"flag":{"value":"b","version":1}}', - }); + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a']); - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a', 'b']); + client.off('change', listener1); + expectStreamUrlIsOpen(fullStreamUrlWithUser); - client.off('change:flag', listener2); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + streamEvents().put({ + data: '{"flag":{"value":"b","version":1}}', + }); - streamEvents().put({ - data: '{"flag":{"value":"c","version":1}}', - }); + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a', 'b']); + client.off('change:flag', listener2); + expectStreamUrlIsOpen(fullStreamUrlWithUser); - done(); + streamEvents().put({ + data: '{"flag":{"value":"c","version":1}}', }); + + expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); }); }); - it('passes the secure mode hash in the stream URL if provided', done => { - const client = platform.testing.makeClient(envName, user, { hash: hash, bootstrap: {} }); + it('passes the secure mode hash in the stream URL if provided', async () => { + const client = platform.testing.makeClient(envName, user, { hash: hash }); + await client.waitForInitialization(); + client.on('change:flagkey', () => {}); - client.on('ready', () => { - client.on('change:flagkey', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash); }); - it('passes withReasons parameter if provided', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, evaluationReasons: true }); + it('passes withReasons parameter if provided', async () => { + const client = platform.testing.makeClient(envName, user, { evaluationReasons: true }); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?withReasons=true'); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?withReasons=true'); }); - it('passes secure mode hash and withReasons if provided', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, hash: hash, evaluationReasons: true }); + it('passes secure mode hash and withReasons if provided', async () => { + const client = platform.testing.makeClient(envName, user, { hash: hash, evaluationReasons: true }); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash + '&withReasons=true'); - done(); - }); + expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash + '&withReasons=true'); }); - it('handles stream ping message by getting flags', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('handles stream ping message by getting flags', async () => { + server.respondWith(jsonResponse({ 'enable-foo': { value: true, version: 1 } })); - client.on('ready', () => { - client.on('change', () => {}); - streamEvents().ping(); - getLastRequest().respond( - 200, - { 'Content-Type': 'application/json' }, - '{"enable-foo":{"value":true,"version":1}}' - ); - expect(client.variation('enable-foo')).toEqual(true); - done(); - }); - }); + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.setStreaming(true); - it('handles stream put message by updating flags', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + streamEvents().ping(); + await asyncSleep(20); // give response handler a chance to execute - client.on('ready', () => { - client.on('change', () => {}); + expect(client.variation('enable-foo')).toEqual(true); + }); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + it('handles stream put message by updating flags', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.setStreaming(true); - expect(client.variation('enable-foo')).toEqual(true); - done(); + streamEvents().put({ + data: '{"enable-foo":{"value":true,"version":1}}', }); + + expect(client.variation('enable-foo')).toEqual(true); }); - it('updates local storage for put message if using local storage', done => { + it('updates local storage for put message if using local storage', async () => { const platform = stubPlatform.defaults(); platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); - - expect(client.variation('enable-foo')).toEqual(true); - const value = platform.testing.getLocalStorageImmediately(lsKey); - expect(JSON.parse(value)).toEqual({ - $schema: 1, - 'enable-foo': { value: true, version: 1 }, - }); - - done(); + streamEvents().put({ + data: '{"enable-foo":{"value":true,"version":1}}', }); + + expect(client.variation('enable-foo')).toEqual(true); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ 'enable-foo': { value: true, version: 1 } }); }); - it('fires global change event when flags are updated from put event', done => { + it('fires global change event when flags are updated from put event', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', changes => { - expect(changes).toEqual({ - 'enable-foo': { current: true, previous: false }, - }); + const receivedChange = promiseListener(); + client.on('change', receivedChange.callback); - done(); - }); + streamEvents().put({ + data: '{"enable-foo":{"value":true,"version":1}}', + }); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const changes = await receivedChange; + expect(changes).toEqual({ + 'enable-foo': { current: true, previous: false }, }); }); - it('does not fire change event if new and old values are equivalent JSON objects', done => { + it('does not fire change event if new and old values are equivalent JSON objects', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'will-change': 3, 'wont-change': { a: 1, b: 2 }, }, }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', changes => { - expect(changes).toEqual({ - 'will-change': { current: 4, previous: 3 }, - }); + const receivedChange = promiseListener(); + client.on('change', receivedChange.callback); - done(); - }); + const putData = { + 'will-change': { value: 4, version: 2 }, + 'wont-change': { value: { b: 2, a: 1 }, version: 2 }, + }; + streamEvents().put({ data: JSON.stringify(putData) }); - const putData = { - 'will-change': { value: 4, version: 2 }, - 'wont-change': { value: { b: 2, a: 1 }, version: 2 }, - }; - streamEvents().put({ data: JSON.stringify(putData) }); + const changes = await receivedChange; + expect(changes).toEqual({ + 'will-change': { current: 4, previous: 3 }, }); }); - it('fires individual change event when flags are updated from put event', done => { + it('fires individual change event when flags are updated from put event', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change:enable-foo', (current, previous) => { - expect(current).toEqual(true); - expect(previous).toEqual(false); + const receivedChange = promiseListener(); + client.on('change:enable-foo', receivedChange.callback); - done(); - }); - - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + streamEvents().put({ + data: '{"enable-foo":{"value":true,"version":1}}', }); + + const args = await receivedChange; + expect(args).toEqual([true, false]); }); - it('handles patch message by updating flag', done => { + it('handles patch message by updating flag', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().patch({ data: '{"key":"enable-foo","value":true,"version":1}' }); + streamEvents().patch({ data: '{"key":"enable-foo","value":true,"version":1}' }); - expect(client.variation('enable-foo')).toEqual(true); - done(); - }); + expect(client.variation('enable-foo')).toEqual(true); }); - it('does not update flag if patch version < flag version', done => { - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{"enable-foo":{"value":"a","version":2}}']); + it('does not update flag if patch version < flag version', async () => { + const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); - const client = platform.testing.makeClient(envName, user); - client.on('ready', () => { - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('enable-foo')).toEqual('a'); - client.on('change', () => {}); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); + streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); - expect(client.variation('enable-foo')).toEqual('a'); - - done(); - }); - server.respond(); + expect(client.variation('enable-foo')).toEqual('a'); }); - it('does not update flag if patch version == flag version', done => { - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{"enable-foo":{"value":"a","version":2}}']); + it('does not update flag if patch version == flag version', async () => { + const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); - const client = platform.testing.makeClient(envName, user); - client.on('ready', () => { - expect(client.variation('enable-foo')).toEqual('a'); - - client.on('change', () => {}); + expect(client.variation('enable-foo')).toEqual('a'); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); + client.setStreaming(true); - expect(client.variation('enable-foo')).toEqual('a'); + streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":2}' }); - done(); - }); - server.respond(); + expect(client.variation('enable-foo')).toEqual('a'); }); - it('updates flag if patch has a version and flag has no version', done => { - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{"enable-foo":{"value":"a"}}']); - - const client = platform.testing.makeClient(envName, user); - client.on('ready', () => { - expect(client.variation('enable-foo')).toEqual('a'); + it('updates flag if patch has a version and flag has no version', async () => { + const initData = makeBootstrap({ 'enable-foo': { value: 'a' } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); - client.on('change', () => {}); + expect(client.variation('enable-foo')).toEqual('a'); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); + client.setStreaming(true); - expect(client.variation('enable-foo')).toEqual('b'); + streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); - done(); - }); - server.respond(); + expect(client.variation('enable-foo')).toEqual('b'); }); - it('updates flag if flag has a version and patch has no version', done => { - const server = sinon.fakeServer.create(); - server.respondWith([200, { 'Content-Type': 'application/json' }, '{"enable-foo":{"value":"a","version":2}}']); - - const client = platform.testing.makeClient(envName, user); - client.on('ready', () => { - expect(client.variation('enable-foo')).toEqual('a'); + it('updates flag if flag has a version and patch has no version', async () => { + const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); - client.on('change', () => {}); + expect(client.variation('enable-foo')).toEqual('a'); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b"}' }); + client.setStreaming(true); - expect(client.variation('enable-foo')).toEqual('b'); + streamEvents().patch({ data: '{"key":"enable-foo","value":"b"}' }); - done(); - }); - server.respond(); + expect(client.variation('enable-foo')).toEqual('b'); }); - it('updates local storage for patch message if using local storage', done => { + it('updates local storage for patch message if using local storage', async () => { const platform = stubPlatform.defaults(); platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); - - expect(client.variation('enable-foo')).toEqual(true); - const value = platform.testing.getLocalStorageImmediately(lsKey); - expect(JSON.parse(value)).toEqual({ - $schema: 1, - 'enable-foo': { value: true, version: 1 }, - }); - - done(); + streamEvents().put({ + data: '{"enable-foo":{"value":true,"version":1}}', }); + + expect(client.variation('enable-foo')).toEqual(true); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ 'enable-foo': { value: true, version: 1 } }); }); - it('fires global change event when flag is updated from patch event', done => { + it('fires global change event when flag is updated from patch event', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', changes => { - expect(changes).toEqual({ - 'enable-foo': { current: true, previous: false }, - }); + const receivedChange = promiseListener(); + client.on('change', receivedChange.callback); - done(); - }); + streamEvents().patch({ + data: '{"key":"enable-foo","value":true,"version":1}', + }); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const changes = await receivedChange; + expect(changes).toEqual({ + 'enable-foo': { current: true, previous: false }, }); }); - it('fires individual change event when flag is updated from patch event', done => { + it('fires individual change event when flag is updated from patch event', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change:enable-foo', (current, previous) => { - expect(current).toEqual(true); - expect(previous).toEqual(false); - - done(); - }); + const receivedChange = promiseListener(); + client.on('change:enable-foo', receivedChange.callback); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + streamEvents().patch({ + data: '{"key":"enable-foo","value":true,"version":1}', }); + + const args = await receivedChange; + expect(args).toEqual([true, false]); }); - it('fires global change event when flag is newly created from patch event', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('fires global change event when flag is newly created from patch event', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', changes => { - expect(changes).toEqual({ - 'enable-foo': { current: true }, - }); + const receivedChange = promiseListener(); + client.on('change', receivedChange.callback); - done(); - }); + streamEvents().patch({ + data: '{"key":"enable-foo","value":true,"version":1}', + }); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const changes = await receivedChange; + expect(changes).toEqual({ + 'enable-foo': { current: true }, }); }); - it('fires global change event when flag is newly created from patch event', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('fires individual change event when flag is newly created from patch event', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change:enable-foo', (current, previous) => { - expect(current).toEqual(true); - expect(previous).toEqual(undefined); + const receivedChange = promiseListener(); + client.on('change:enable-foo', receivedChange.callback); - done(); - }); - - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + streamEvents().patch({ + data: '{"key":"enable-foo","value":true,"version":1}', }); + + const args = await receivedChange; + expect(args).toEqual([true, undefined]); }); - it('handles delete message by deleting flag', done => { + it('handles delete message by deleting flag', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); - - expect(client.variation('enable-foo')).toBeUndefined(); - done(); + streamEvents().delete({ + data: '{"key":"enable-foo","version":1}', }); - }); - - it('handles delete message for unknown flag by storing placeholder', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().delete({ - data: '{"key":"mystery","version":3}', - }); + expect(client.variation('enable-foo')).toBeUndefined(); + }); - // The following patch message should be ignored because it has a lower version than the deleted placeholder - streamEvents().patch({ - data: '{"key":"mystery","value":"yes","version":2}', - }); + it('handles delete message for unknown flag by storing placeholder', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.setStreaming(true); - expect(client.variation('mystery')).toBeUndefined(); - done(); + streamEvents().delete({ + data: '{"key":"mystery","version":3}', }); - }); - it('ignores delete message with lower version', done => { - const bootstrapData = { flag: 'yes', $flagsState: { flag: { version: 3 } } }; - const client = platform.testing.makeClient(envName, user, { bootstrap: bootstrapData }); + // The following patch message should be ignored because it has a lower version than the deleted placeholder + streamEvents().patch({ + data: '{"key":"mystery","value":"yes","version":2}', + }); - client.on('ready', () => { - client.on('change', () => {}); + expect(client.variation('mystery')).toBeUndefined(); + }); - streamEvents().delete({ - data: '{"key":"flag","version":2}', - }); + it('ignores delete message with lower version', async () => { + const initData = makeBootstrap({ flag: { value: 'yes', version: 3 } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); + client.setStreaming(true); - expect(client.variation('flag')).toEqual('yes'); - done(); + streamEvents().delete({ + data: '{"key":"flag","version":2}', }); + + expect(client.variation('flag')).toEqual('yes'); }); - it('fires global change event when flag is deleted', done => { + it('fires global change event when flag is deleted', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': true } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change', changes => { - expect(changes).toEqual({ - 'enable-foo': { previous: true }, - }); + const receivedChange = promiseListener(); + client.on('change', receivedChange.callback); - done(); - }); + streamEvents().delete({ + data: '{"key":"enable-foo","version":1}', + }); - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + const changes = await receivedChange; + expect(changes).toEqual({ + 'enable-foo': { previous: true }, }); }); - it('fires individual change event when flag is deleted', done => { + it('fires individual change event when flag is deleted', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': true } }); + await client.waitForInitialization(); - client.on('ready', () => { - client.on('change:enable-foo', (current, previous) => { - expect(current).toEqual(undefined); - expect(previous).toEqual(true); + const receivedChange = promiseListener(); + client.on('change:enable-foo', receivedChange.callback); - done(); - }); - - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + streamEvents().delete({ + data: '{"key":"enable-foo","version":1}', }); + + const args = await receivedChange; + expect(args).toEqual([undefined, true]); }); - it('updates local storage for delete message if using local storage', done => { + it('updates local storage for delete message if using local storage', async () => { const platform = stubPlatform.defaults(); platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); + await client.waitForInitialization(); + client.setStreaming(true); - client.on('ready', () => { - client.on('change', () => {}); - - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); - - expect(client.variation('enable-foo')).toEqual(undefined); - const value = platform.testing.getLocalStorageImmediately(lsKey); - expect(JSON.parse(value)).toEqual({ - $schema: 1, - 'enable-foo': { version: 1, deleted: true }, - }); - - done(); + streamEvents().delete({ + data: '{"key":"enable-foo","version":1}', }); + + expect(client.variation('enable-foo')).toEqual(undefined); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ 'enable-foo': { version: 1, deleted: true } }); }); - it('reconnects to stream if the user changes', done => { + it('reconnects to stream if the user changes', async () => { const user2 = { key: 'user2' }; const encodedUser2 = 'eyJrZXkiOiJ1c2VyMiJ9'; - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); - - client.on('ready', () => { - client.on('change', () => {}); - - expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser]).toBeDefined(); + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + client.setStreaming(true); - client.identify(user2, null, () => { - expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser2]).toBeDefined(); - done(); - }); + expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser]).toBeDefined(); - utils.onNextTick(() => - getLastRequest().respond(200, { 'Content-Type': 'application/json' }, '{"enable-foo": {"value": true}}') - ); - }); + await client.identify(user2); + expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser2]).toBeDefined(); }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/LDClient-test.js b/packages/ldclient-js-common/src/__tests__/LDClient-test.js index 3c2af9d5..2bd822cc 100644 --- a/packages/ldclient-js-common/src/__tests__/LDClient-test.js +++ b/packages/ldclient-js-common/src/__tests__/LDClient-test.js @@ -1,347 +1,327 @@ -import sinon from 'sinon'; import semverCompare from 'semver-compare'; import * as stubPlatform from './stubPlatform'; +import { + asyncify, + errorResponse, + jsonResponse, + makeBootstrap, + makeDefaultServer, + numericUser, + promiseListener, + stringifiedNumericUser, +} from './testUtils'; + import * as LDClient from '../index'; +import * as errors from '../errors'; import * as messages from '../messages'; import * as utils from '../utils'; describe('LDClient', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const user = { key: 'user' }; - let xhr; - let requests = []; let platform; + let server; beforeEach(() => { - xhr = sinon.useFakeXMLHttpRequest(); - xhr.onCreate = function(req) { - requests.push(req); - }; - + server = makeDefaultServer(); platform = stubPlatform.defaults(); }); afterEach(() => { - requests = []; - xhr.restore(); + server.restore(); }); - function getLastRequest() { - return requests[requests.length - 1]; - } - it('should exist', () => { expect(LDClient).toBeDefined(); }); describe('initialization', () => { - it('should trigger the ready event', done => { - const handleReady = jest.fn(); - const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); - - client.on('ready', handleReady); + it('should trigger the ready event', async () => { + const client = platform.testing.makeClient(envName, user); + const gotReady = promiseListener(); + client.on('ready', gotReady.callback); - setTimeout(() => { - expect(handleReady).toHaveBeenCalled(); - expect(platform.testing.logger.output.info).toEqual([messages.clientInitialized()]); - done(); - }, 0); + await gotReady; + expect(platform.testing.logger.output.info).toEqual([messages.clientInitialized()]); }); - it('should trigger the initialized event', done => { - const handleReady = jest.fn(); - const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); - - client.on('initialized', handleReady); + it('should trigger the initialized event', async () => { + const client = platform.testing.makeClient(envName, user); + const gotInited = promiseListener(); + client.on('initialized', gotInited.callback); - setTimeout(() => { - expect(handleReady).toHaveBeenCalled(); - done(); - }, 0); + await gotInited; }); - it('should emit an error when an invalid samplingInterval is specified', done => { + it('should emit an error when an invalid samplingInterval is specified', async () => { const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, samplingInterval: 'totally not a number', }); + const gotError = promiseListener(); + client.on('error', gotError.callback); - client.on('error', err => { - expect(err.message).toEqual('Invalid sampling interval configured. Sampling interval must be an integer >= 0.'); - done(); - }); + const err = await gotError; + expect(err.message).toEqual('Invalid sampling interval configured. Sampling interval must be an integer >= 0.'); }); - it('should emit an error when initialize is called without an environment key', done => { - const client = platform.testing.makeClient('', user, { - bootstrap: {}, - }); - client.on('error', err => { - expect(err.message).toEqual(messages.environmentNotSpecified()); - done(); - }); + it('should emit an error when initialize is called without an environment key', async () => { + const client = platform.testing.makeClient('', user); + const gotError = promiseListener(); + client.on('error', gotError.callback); + + const err = await gotError; + expect(err.message).toEqual(messages.environmentNotSpecified()); }); - it('should emit an error when an invalid environment key is specified', done => { + it('should emit an error when an invalid environment key is specified', async () => { + server.respondWith(errorResponse(404)); + const client = platform.testing.makeClient('abc', user); - client.on('error', err => { - expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); - done(); - }); - client.waitForInitialization().catch(() => {}); // jest doesn't like unhandled rejections - requests[0].respond(404); + const gotError = promiseListener(); + client.on('error', gotError.callback); + + await expect(client.waitForInitialization()).rejects.toThrow(); + + const err = await gotError; + expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); }); - it('should emit a failure event when an invalid environment key is specified', done => { + it('should emit a failure event when an invalid environment key is specified', async () => { + server.respondWith(errorResponse(404)); + const client = platform.testing.makeClient('abc', user); - client.on('failed', err => { - expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); - done(); - }); - client.waitForInitialization().catch(() => {}); - requests[0].respond(404); + const gotFailed = promiseListener(); + client.on('failed', gotFailed.callback); + + await expect(client.waitForInitialization()).rejects.toThrow(); + + const err = await gotFailed; + expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); }); - it('returns default values when an invalid environment key is specified', done => { + it('returns default values when an invalid environment key is specified', async () => { + server.respondWith(errorResponse(404)); + const client = platform.testing.makeClient('abc', user); - client.on('error', () => { - expect(client.variation('flag-key', 1)).toEqual(1); - done(); - }); - client.waitForInitialization().catch(() => {}); - requests[0].respond(404); + + await expect(client.waitForInitialization()).rejects.toThrow(); + + expect(client.variation('flag-key', 1)).toEqual(1); }); - it('fetches flag settings if bootstrap is not provided (without reasons)', () => { - platform.testing.makeClient(envName, user, {}); - expect(/sdk\/eval/.test(requests[0].url)).toEqual(true); - expect(/withReasons=true/.test(requests[0].url)).toEqual(false); + it('fetches flag settings if bootstrap is not provided (without reasons)', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + + expect(/sdk\/eval/.test(server.requests[0].url)).toEqual(true); + expect(/withReasons=true/.test(server.requests[0].url)).toEqual(false); }); - it('fetches flag settings if bootstrap is not provided (with reasons)', () => { - platform.testing.makeClient(envName, user, { evaluationReasons: true }); - expect(/sdk\/eval/.test(requests[0].url)).toEqual(true); - expect(/withReasons=true/.test(requests[0].url)).toEqual(true); + it('fetches flag settings if bootstrap is not provided (with reasons)', async () => { + const client = platform.testing.makeClient(envName, user, { evaluationReasons: true }); + await client.waitForInitialization(); + + expect(/sdk\/eval/.test(server.requests[0].url)).toEqual(true); + expect(/withReasons=true/.test(server.requests[0].url)).toEqual(true); }); - it('should not fetch flag settings if bootstrap is provided', () => { - platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); - expect(requests.length).toEqual(0); + it('should not fetch flag settings if bootstrap is provided', async () => { + const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + await client.waitForInitialization(); + + expect(server.requests.length).toEqual(0); }); - it('logs warning when bootstrap object uses old format', () => { - platform.testing.makeClient(envName, user, { bootstrap: { foo: 'bar' } }); + it('logs warning when bootstrap object uses old format', async () => { + const client = platform.testing.makeClient(envName, user, { bootstrap: { foo: 'bar' } }); + await client.waitForInitialization(); expect(platform.testing.logger.output.warn).toEqual([messages.bootstrapOldFormat()]); }); - it('does not log warning when bootstrap object uses new format', () => { - platform.testing.makeClient(envName, user, { - bootstrap: { foo: 'bar', $flagsState: { foo: { version: 1 } } }, - }); + it('does not log warning when bootstrap object uses new format', async () => { + const initData = makeBootstrap({ foo: { value: 'bar', version: 1 } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); expect(platform.testing.logger.output.warn).toEqual([]); + expect(client.variation('foo')).toEqual('bar'); }); it('should contain package version', () => { - // Arrange const version = LDClient.version; - - // Act: all client bundles above 1.0.7 should contain package version - // https://github.com/substack/semver-compare + // All client bundles above 1.0.7 should contain package version const result = semverCompare(version, '1.0.6'); - - // Assert expect(result).toEqual(1); }); - it('should not warn when tracking a custom event', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('should not warn when tracking a custom event', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.track('known'); - expect(platform.testing.logger.output.warn).toEqual([]); - done(); - }); + client.track('known'); + expect(platform.testing.logger.output.warn).toEqual([]); }); - it('should emit an error when tracking a non-string custom event', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); - client.on('ready', () => { - const badCustomEventKeys = [123, [], {}, null, undefined]; - badCustomEventKeys.forEach(key => { - platform.testing.logger.reset(); - client.track(key); - expect(platform.testing.logger.output.error).toEqual([messages.unknownCustomEventKey(key)]); - }); - done(); - }); - }); + it('should emit an error when tracking a non-string custom event', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - it('should emit an error event if there was an error fetching flags', done => { - const server = sinon.fakeServer.create(); - server.respondWith(req => { - req.respond(503); + const badCustomEventKeys = [123, [], {}, null, undefined]; + badCustomEventKeys.forEach(key => { + platform.testing.logger.reset(); + client.track(key); + expect(platform.testing.logger.output.error).toEqual([messages.unknownCustomEventKey(key)]); }); + }); - const client = platform.testing.makeClient(envName, user, {}); + it('should emit an error event if there was an error fetching flags', async () => { + server.respondWith(errorResponse(503)); - const handleError = jest.fn(); - client.on('error', handleError); - server.respond(); + const client = platform.testing.makeClient(envName, user); - client.waitForInitialization().catch(() => {}); + const gotError = promiseListener(); + client.on('error', gotError.callback); - setTimeout(() => { - expect(handleError).toHaveBeenCalled(); - done(); - }, 0); + await expect(client.waitForInitialization()).rejects.toThrow(); + await gotError; }); it('should warn about missing user on first event', () => { - const sandbox = sinon.createSandbox(); const client = platform.testing.makeClient(envName, null); client.track('eventkey', null); - sandbox.restore(); expect(platform.testing.logger.output.warn).toEqual([messages.eventWithoutUser()]); }); - function verifyCustomHeader(sendLDHeaders, shouldGetHeaders) { - platform.testing.makeClient(envName, user, { sendLDHeaders: sendLDHeaders }); - const request = requests[0]; + async function verifyCustomHeader(sendLDHeaders, shouldGetHeaders) { + const client = platform.testing.makeClient(envName, user, { sendLDHeaders: sendLDHeaders }); + await client.waitForInitialization(); + const request = server.requests[0]; expect(request.requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( shouldGetHeaders ? utils.getLDUserAgentString(platform) : undefined ); } - it('sends custom header by default', () => { - verifyCustomHeader(undefined, true); - }); + it('sends custom header by default', () => verifyCustomHeader(undefined, true)); - it('sends custom header if sendLDHeaders is true', () => { - verifyCustomHeader(true, true); - }); + it('sends custom header if sendLDHeaders is true', () => verifyCustomHeader(true, true)); - it('does not send custom header if sendLDHeaders is false', () => { - verifyCustomHeader(undefined, true); + it('does not send custom header if sendLDHeaders is false', () => verifyCustomHeader(undefined, true)); + + it('sanitizes the user', async () => { + const client = platform.testing.makeClient(envName, numericUser); + await client.waitForInitialization(); + expect(client.getUser()).toEqual(stringifiedNumericUser); }); - }); - describe('waitUntilReady', () => { - it('should resolve waitUntilReady promise when ready', done => { - const handleReady = jest.fn(); - const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); + it('provides a persistent key for an anonymous user with no key', async () => { + const anonUser = { anonymous: true, country: 'US' }; + const client0 = platform.testing.makeClient(envName, anonUser); + await client0.waitForInitialization(); - client.waitUntilReady().then(handleReady); + const newUser0 = client0.getUser(); + expect(newUser0.key).toEqual(expect.anything()); + expect(newUser0).toMatchObject(anonUser); - client.on('ready', () => { - setTimeout(() => { - expect(handleReady).toHaveBeenCalled(); - done(); - }, 0); - }); + const client1 = platform.testing.makeClient(envName, anonUser); + await client1.waitForInitialization(); + + const newUser1 = client1.getUser(); + expect(newUser1).toEqual(newUser0); }); - it('should resolve waitUntilReady promise after ready event was already emitted', done => { - const handleInitialReady = jest.fn(); - const handleReady = jest.fn(); - const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); + it('provides a key for an anonymous user with no key, even if local storage is unavailable', async () => { + platform.localStorage = null; - client.on('ready', handleInitialReady); + const anonUser = { anonymous: true, country: 'US' }; + const client0 = platform.testing.makeClient(envName, anonUser); + await client0.waitForInitialization(); - setTimeout(() => { - client.waitUntilReady().then(handleReady); + const newUser0 = client0.getUser(); + expect(newUser0.key).toEqual(expect.anything()); + expect(newUser0).toMatchObject(anonUser); - setTimeout(() => { - expect(handleInitialReady).toHaveBeenCalled(); - expect(handleReady).toHaveBeenCalled(); - done(); - }, 0); - }, 0); + const client1 = platform.testing.makeClient(envName, anonUser); + await client1.waitForInitialization(); + + const newUser1 = client1.getUser(); + expect(newUser1.key).toEqual(expect.anything()); + // This key is probably different from newUser0.key, but that depends on execution time, so we can't count on it. + expect(newUser1).toMatchObject(anonUser); }); }); - describe('waitForInitialization', () => { - it('resolves promise on successful init', done => { - const handleReady = jest.fn(); - const client = platform.testing.makeClient(envName, user, { - bootstrap: {}, - }); + describe('waitUntilReady', () => { + it('should resolve waitUntilReady promise when ready', async () => { + const client = platform.testing.makeClient(envName, user); + const gotReady = promiseListener(); + client.on('ready', gotReady.callback); - client.waitForInitialization().then(handleReady); + await gotReady; + await client.waitUntilReady(); + }); + }); - client.on('ready', () => { - setTimeout(() => { - expect(handleReady).toHaveBeenCalled(); - done(); - }, 0); - }); + describe('waitForInitialization', () => { + it('resolves promise on successful init', async () => { + const client = platform.testing.makeClient(envName, user); + const gotReady = promiseListener(); + client.on('ready', gotReady.callback); + + await gotReady; + await client.waitForInitialization(); }); - it('rejects promise if flags request fails', done => { - const client = platform.testing.makeClient('abc', user, {}); - client.waitForInitialization().catch(err => { - expect(err.message).toEqual('Error fetching flag settings: ' + messages.environmentNotFound()); - done(); - }); - requests[0].respond(404); + it('rejects promise if flags request fails', async () => { + server.respondWith(errorResponse(404)); + + const client = platform.testing.makeClient('abc', user); + const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); + await expect(client.waitForInitialization()).rejects.toThrow(err); }); }); describe('variation', () => { - it('returns value for an existing flag - from bootstrap', () => { + it('returns value for an existing flag - from bootstrap', async () => { const client = platform.testing.makeClient(envName, user, { - bootstrap: { foo: 'bar', $flagsState: { foo: { version: 1 } } }, + bootstrap: makeBootstrap({ foo: { value: 'bar', version: 1 } }), }); + await client.waitForInitialization(); expect(client.variation('foo')).toEqual('bar'); }); - it('returns value for an existing flag - from bootstrap with old format', () => { + it('returns value for an existing flag - from bootstrap with old format', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { foo: 'bar' }, }); + await client.waitForInitialization(); expect(client.variation('foo')).toEqual('bar'); }); - it('returns value for an existing flag - from polling', done => { - const client = platform.testing.makeClient(envName, user, {}); - client.on('ready', () => { - expect(client.variation('enable-foo', 1)).toEqual(true); - done(); - }); - requests[0].respond( - 200, - { 'Content-Type': 'application/json' }, - '{"enable-foo": {"value": true, "version": 1, "variation": 2}}' - ); + it('returns value for an existing flag - from polling', async () => { + server.respondWith(jsonResponse({ 'enable-foo': { value: true, version: 1, variation: 2 } })); + + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + + expect(client.variation('enable-foo', 1)).toEqual(true); }); - it('returns default value for flag that had null value', done => { - const client = platform.testing.makeClient(envName, user, {}); - client.on('ready', () => { - expect(client.variation('foo', 'default')).toEqual('default'); - done(); - }); - requests[0].respond(200, { 'Content-Type': 'application/json' }, '{"foo": {"value": null, "version": 1}}'); + it('returns default value for flag that had null value', async () => { + server.respondWith(jsonResponse({ 'enable-foo': { value: null, version: 1 } })); + + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + + expect(client.variation('foo', 'default')).toEqual('default'); }); - it('returns default value for unknown flag', () => { - const client = platform.testing.makeClient(envName, user, { - bootstrap: { $flagsState: {} }, - }); + it('returns default value for unknown flag', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); expect(client.variation('foo', 'default')).toEqual('default'); }); @@ -349,52 +329,50 @@ describe('LDClient', () => { describe('variationDetail', () => { const reason = { kind: 'FALLTHROUGH' }; - it('returns details for an existing flag - from bootstrap', () => { + it('returns details for an existing flag - from bootstrap', async () => { const client = platform.testing.makeClient(envName, user, { - bootstrap: { foo: 'bar', $flagsState: { foo: { version: 1, variation: 2, reason: reason } } }, + bootstrap: makeBootstrap({ foo: { value: 'bar', version: 1, variation: 2, reason: reason } }), }); + await client.waitForInitialization(); expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); }); - it('returns details for an existing flag - from bootstrap with old format', () => { + it('returns details for an existing flag - from bootstrap with old format', async () => { const client = platform.testing.makeClient(envName, user, { bootstrap: { foo: 'bar' }, }); + await client.waitForInitialization(); expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: null, reason: null }); }); - it('returns details for an existing flag - from polling', done => { - const client = platform.testing.makeClient(envName, user, {}); - client.on('ready', () => { - expect(client.variationDetail('foo', 'default')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); - done(); - }); - requests[0].respond( - 200, - { 'Content-Type': 'application/json' }, - '{"foo": {"value": "bar", "version": 1, "variation": 2, "reason":' + JSON.stringify(reason) + '}}' - ); + it('returns details for an existing flag - from polling', async () => { + const pollData = { foo: { value: 'bar', version: 1, variation: 2, reason: reason } }; + server.respondWith(jsonResponse(pollData)); + + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + + expect(client.variationDetail('foo', 'default')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); }); - it('returns default value for flag that had null value', done => { - const client = platform.testing.makeClient(envName, user, {}); - client.on('ready', () => { - expect(client.variationDetail('foo', 'default')).toEqual({ - value: 'default', - variationIndex: null, - reason: null, - }); - done(); + it('returns default value for flag that had null value', async () => { + server.respondWith(jsonResponse({ foo: { value: null, version: 1 } })); + + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); + + expect(client.variationDetail('foo', 'default')).toEqual({ + value: 'default', + variationIndex: null, + reason: null, }); - requests[0].respond(200, { 'Content-Type': 'application/json' }, '{"foo": {"value": null, "version": 1}}'); }); - it('returns default value and error for unknown flag', () => { - const client = platform.testing.makeClient(envName, user, { - bootstrap: { $flagsState: {} }, - }); + it('returns default value and error for unknown flag', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); expect(client.variationDetail('foo', 'default')).toEqual({ value: 'default', @@ -405,18 +383,12 @@ describe('LDClient', () => { }); describe('allFlags', () => { - it('returns flag values', done => { - const client = platform.testing.makeClient(envName, user, {}); - client.on('ready', () => { - expect(client.allFlags()).toEqual({ key1: 'value1', key2: 'value2' }); - done(); - }); - requests[0].respond( - 200, - { 'Content-Type': 'application/json' }, - '{"key1": {"value": "value1", "version": 1, "variation": 2},' + - '"key2": {"value": "value2", "version": 1, "variation": 2}}' - ); + it('returns flag values', async () => { + const initData = makeBootstrap({ key1: { value: 'value1' }, key2: { value: 'value2' } }); + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); + + expect(client.allFlags()).toEqual({ key1: 'value1', key2: 'value2' }); }); it('returns empty map if client is not initialized', () => { @@ -426,107 +398,82 @@ describe('LDClient', () => { }); describe('identify', () => { - it('updates flag values when the user changes', done => { + it('updates flag values when the user changes', async () => { const user2 = { key: 'user2' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.identify(user2, null, () => { - expect(client.variation('enable-foo')).toEqual(true); - done(); - }); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - utils.onNextTick(() => - getLastRequest().respond(200, { 'Content-Type': 'application/json' }, '{"enable-foo": {"value": true}}') - ); - }); + await client.identify(user2); + expect(client.variation('enable-foo')).toEqual(true); }); - it('yields map of flag values as the result of identify()', done => { + it('yields map of flag values as the result of identify()', async () => { const user2 = { key: 'user2' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.identify(user2, null).then(flagMap => { - expect(flagMap).toEqual({ 'enable-foo': true }); - done(); - }); + server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - utils.onNextTick(() => - getLastRequest().respond(200, { 'Content-Type': 'application/json' }, '{"enable-foo": {"value": true}}') - ); - }); + const flagMap = await client.identify(user2); + expect(flagMap).toEqual({ 'enable-foo': true }); }); - it('returns an error when identify is called with null user', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('returns an error when identify is called with null user', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.identify(null).then( - () => { - throw Error('should not have succeeded'); - }, - () => { - done(); - } - ); - }); + await expect(client.identify(null)).rejects.toThrow(); }); - it('returns an error when identify is called with user with no key', done => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); + it('returns an error when identify is called with user with no key', async () => { + const client = platform.testing.makeClient(envName, user); + await client.waitForInitialization(); - client.on('ready', () => { - client.identify({ country: 'US' }).then( - () => { - throw Error('should not have succeeded'); - }, - () => { - done(); - } - ); - }); + await expect(client.identify({ country: 'US' })).rejects.toThrow(); }); - it('does not change flag values after identify is called with null user', done => { - const data = { foo: 'bar' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: data }); - - client.on('ready', () => { - expect(client.variation('foo', 'x')).toEqual('bar'); - client.identify(null).then( - () => { - throw Error('should not have succeeded'); - }, - () => { - expect(client.variation('foo', 'x')).toEqual('bar'); - done(); - } - ); - }); + it('does not change flag values after identify is called with null user', async () => { + const initData = { foo: 'bar' }; + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); + + expect(client.variation('foo', 'x')).toEqual('bar'); + + await expect(client.identify(null)).rejects.toThrow(); + + expect(client.variation('foo', 'x')).toEqual('bar'); }); - it('does not change flag values after identify is called with invalid user', done => { - const data = { foo: 'bar' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: data }); - - client.on('ready', () => { - expect(client.variation('foo', 'x')).toEqual('bar'); - client.identify({ country: 'US' }).then( - () => { - throw Error('should not have succeeded'); - }, - () => { - expect(client.variation('foo', 'x')).toEqual('bar'); - done(); - } - ); - }); + it('does not change flag values after identify is called with invalid user', async () => { + const initData = { foo: 'bar' }; + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); + + expect(client.variation('foo', 'x')).toEqual('bar'); + + await expect(client.identify({ country: 'US' })).rejects.toThrow(); + + expect(client.variation('foo', 'x')).toEqual('bar'); + }); + + it('provides a persistent key for an anonymous user with no key', async () => { + const initData = { foo: 'bar' }; + const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); + await client.waitForInitialization(); + + const anonUser = { anonymous: true, country: 'US' }; + await client.identify(anonUser); + + const newUser = client.getUser(); + expect(newUser.key).toEqual(expect.anything()); + expect(newUser).toMatchObject(anonUser); }); }); describe('initializing with stateProvider', () => { - it('immediately uses initial state if available, and does not make an HTTP request', done => { + it('immediately uses initial state if available, and does not make an HTTP request', async () => { const user = { key: 'user' }; const state = { environment: 'env', @@ -536,20 +483,20 @@ describe('LDClient', () => { const sp = stubPlatform.mockStateProvider(state); const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - expect(client.variation('flagkey')).toEqual('value'); - expect(requests.length).toEqual(0); + await client.waitForInitialization(); - client.waitForInitialization().then(done); + expect(client.variation('flagkey')).toEqual('value'); + expect(server.requests.length).toEqual(0); }); it('defers initialization if initial state not available, and does not make an HTTP request', () => { const sp = stubPlatform.mockStateProvider(null); platform.testing.makeClient(null, null, { stateProvider: sp }); - expect(requests.length).toEqual(0); + expect(server.requests.length).toEqual(0); }); - it('finishes initialization on receiving init event', done => { + it('finishes initialization on receiving init event', async () => { const user = { key: 'user' }; const state = { environment: 'env', @@ -562,13 +509,11 @@ describe('LDClient', () => { sp.emit('init', state); - client.waitForInitialization().then(() => { - expect(client.variation('flagkey')).toEqual('value'); - done(); - }); + await client.waitForInitialization(); + expect(client.variation('flagkey')).toEqual('value'); }); - it('updates flags on receiving update event', done => { + it('updates flags on receiving update event', async () => { const user = { key: 'user' }; const state0 = { environment: 'env', @@ -578,27 +523,24 @@ describe('LDClient', () => { const sp = stubPlatform.mockStateProvider(state0); const client = platform.testing.makeClient(null, null, { stateProvider: sp }); + await client.waitForInitialization(); - client.waitForInitialization().then(() => { - expect(client.variation('flagkey')).toEqual('value0'); + expect(client.variation('flagkey')).toEqual('value0'); - const state1 = { - flags: { flagkey: { value: 'value1' } }, - }; + const state1 = { + flags: { flagkey: { value: 'value1' } }, + }; - client.on('change:flagkey', (newValue, oldValue) => { - expect(newValue).toEqual('value1'); - expect(oldValue).toEqual('value0'); - expect(client.variation('flagkey')).toEqual('value1'); + const gotChange = promiseListener(); + client.on('change:flagkey', gotChange.callback); - done(); - }); + sp.emit('update', state1); - sp.emit('update', state1); - }); + const args = await gotChange; + expect(args).toEqual(['value1', 'value0']); }); - it('disables identify()', done => { + it('disables identify()', async () => { const user = { key: 'user' }; const user1 = { key: 'user1' }; const state = { environment: 'env', user: user, flags: { flagkey: { value: 'value' } } }; @@ -608,15 +550,56 @@ describe('LDClient', () => { sp.emit('init', state); - client.waitForInitialization().then(() => { - client.identify(user1, null, (err, newFlags) => { - expect(err).toEqual(null); - expect(newFlags).toEqual({ flagkey: 'value' }); - expect(requests.length).toEqual(0); - expect(platform.testing.logger.output.warn).toEqual([messages.identifyDisabled()]); - done(); - }); - }); + await client.waitForInitialization(); + const newFlags = await client.identify(user1); + + expect(newFlags).toEqual({ flagkey: 'value' }); + expect(server.requests.length).toEqual(0); + expect(platform.testing.logger.output.warn).toEqual([messages.identifyDisabled()]); + }); + }); + + describe('close()', () => { + it('flushes events', async () => { + const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); + await client.waitForInitialization(); + + await client.close(); + + expect(server.requests.length).toEqual(1); + const data = JSON.parse(server.requests[0].requestBody); + expect(data.length).toEqual(1); + expect(data[0].kind).toEqual('identify'); + }); + + it('does nothing if called twice', async () => { + const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); + await client.waitForInitialization(); + + await client.close(); + + expect(server.requests.length).toEqual(1); + + await client.close(); + + expect(server.requests.length).toEqual(1); + }); + + it('is not rejected if flush fails', async () => { + server.respondWith(errorResponse(401)); + const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); + await client.waitForInitialization(); + + await client.close(); // shouldn't throw or have an unhandled rejection + }); + + it('can take a callback instead of returning a promise', async () => { + const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); + await client.waitForInitialization(); + + await asyncify(cb => client.close(cb)); + + expect(server.requests.length).toEqual(1); }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/Requestor-test.js b/packages/ldclient-js-common/src/__tests__/Requestor-test.js index 5936bdda..858c0624 100644 --- a/packages/ldclient-js-common/src/__tests__/Requestor-test.js +++ b/packages/ldclient-js-common/src/__tests__/Requestor-test.js @@ -1,6 +1,7 @@ -import sinon from 'sinon'; import * as stubPlatform from './stubPlatform'; +import { errorResponse, jsonResponse, makeDefaultServer } from './testUtils'; import Requestor from '../Requestor'; +import * as errors from '../errors'; import * as messages from '../messages'; import * as utils from '../utils'; @@ -13,170 +14,126 @@ describe('Requestor', () => { const platform = stubPlatform.defaults(); const logger = stubPlatform.logger(); let server; - let seq = 0; beforeEach(() => { - server = sinon.fakeServer.create(); + server = makeDefaultServer(); }); afterEach(() => { server.restore(); }); - it('should always call the callback', () => { - const handleOne = sinon.spy(); - const handleTwo = sinon.spy(); - + it('resolves on success', async () => { const requestor = Requestor(platform, defaultConfig, 'FAKE_ENV', logger); - requestor.fetchFlagSettings({ key: 'user1' }, 'hash1', handleOne); - requestor.fetchFlagSettings({ key: 'user2' }, 'hash2', handleTwo); - - server.respondWith(req => { - seq++; - req.respond(200, { 'Content-type': 'application/json' }, JSON.stringify({ tag: seq })); - }); - - server.respond(); + await requestor.fetchFlagSettings({ key: 'user1' }, 'hash1'); + await requestor.fetchFlagSettings({ key: 'user2' }, 'hash2'); expect(server.requests).toHaveLength(2); - expect(handleOne.args[0]).toEqual(handleTwo.args[0]); }); - it('should make requests with the GET verb if useReport is disabled', () => { + it('makes requests with the GET verb if useReport is disabled', async () => { const config = Object.assign({}, defaultConfig, { useReport: false }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].method).toEqual('GET'); }); - it('should make requests with the REPORT verb with a payload if useReport is enabled', () => { + it('makes requests with the REPORT verb with a payload if useReport is enabled', async () => { const config = Object.assign({}, defaultConfig, { useReport: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].method).toEqual('REPORT'); expect(server.requests[0].requestBody).toEqual(JSON.stringify(user)); }); - it('should include environment and user in GET URL', () => { + it('includes environment and user in GET URL', async () => { const requestor = Requestor(platform, defaultConfig, env, logger); - requestor.fetchFlagSettings(user, null, sinon.spy()); + await requestor.fetchFlagSettings(user, null); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}`); }); - it('should include environment, user, and hash in GET URL', () => { + it('includes environment, user, and hash in GET URL', async () => { const requestor = Requestor(platform, defaultConfig, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?h=hash1`); }); - it('should include environment, user, and withReasons in GET URL', () => { + it('includes environment, user, and withReasons in GET URL', async () => { const config = Object.assign({}, defaultConfig, { evaluationReasons: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, null, sinon.spy()); + await requestor.fetchFlagSettings(user, null); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?withReasons=true`); }); - it('should include environment, user, hash, and withReasons in GET URL', () => { + it('includes environment, user, hash, and withReasons in GET URL', async () => { const config = Object.assign({}, defaultConfig, { evaluationReasons: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?h=hash1&withReasons=true`); }); - it('should include environment in REPORT URL', () => { + it('includes environment in REPORT URL', async () => { const config = Object.assign({}, defaultConfig, { useReport: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, null, sinon.spy()); + await requestor.fetchFlagSettings(user, null); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user`); }); - it('should include environment and hash in REPORT URL', () => { + it('includes environment and hash in REPORT URL', async () => { const config = Object.assign({}, defaultConfig, { useReport: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?h=hash1`); }); - it('should include environment and withReasons in REPORT URL', () => { + it('includes environment and withReasons in REPORT URL', async () => { const config = Object.assign({}, defaultConfig, { useReport: true, evaluationReasons: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, null, sinon.spy()); + await requestor.fetchFlagSettings(user, null); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?withReasons=true`); }); - it('should include environment, hash, and withReasons in REPORT URL', () => { + it('includes environment, hash, and withReasons in REPORT URL', async () => { const config = Object.assign({}, defaultConfig, { useReport: true, evaluationReasons: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests).toHaveLength(1); expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?h=hash1&withReasons=true`); }); - it('should call the each callback at most once', () => { - const handleOne = sinon.spy(); - const handleTwo = sinon.spy(); - const handleThree = sinon.spy(); - const handleFour = sinon.spy(); - const handleFive = sinon.spy(); - - server.respondWith(req => { - seq++; - req.respond(200, { 'Content-type': 'application/json' }, JSON.stringify({ tag: seq })); - }); - - const requestor = Requestor(platform, defaultConfig, env, logger); - requestor.fetchFlagSettings({ key: 'user1' }, 'hash1', handleOne); - server.respond(); - requestor.fetchFlagSettings({ key: 'user2' }, 'hash2', handleTwo); - server.respond(); - requestor.fetchFlagSettings({ key: 'user3' }, 'hash3', handleThree); - server.respond(); - requestor.fetchFlagSettings({ key: 'user4' }, 'hash4', handleFour); - server.respond(); - requestor.fetchFlagSettings({ key: 'user5' }, 'hash5', handleFive); - server.respond(); - - expect(server.requests).toHaveLength(5); - expect(handleOne.calledOnce).toEqual(true); - expect(handleTwo.calledOnce).toEqual(true); - expect(handleThree.calledOnce).toEqual(true); - expect(handleFour.calledOnce).toEqual(true); - expect(handleFive.calledOnce).toEqual(true); - }); - - it('should send custom user-agent header in GET mode when sendLDHeaders is true', () => { + it('sends custom user-agent header in GET mode when sendLDHeaders is true', async () => { const config = Object.assign({}, defaultConfig, { sendLDHeaders: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests.length).toEqual(1); expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( @@ -184,10 +141,10 @@ describe('Requestor', () => { ); }); - it('should send custom user-agent header in REPORT mode when sendLDHeaders is true', () => { + it('sends custom user-agent header in REPORT mode when sendLDHeaders is true', async () => { const config = Object.assign({}, defaultConfig, { useReport: true, sendLDHeaders: true }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests.length).toEqual(1); expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( @@ -195,23 +152,83 @@ describe('Requestor', () => { ); }); - it('should NOT send custom user-agent header when sendLDHeaders is false', () => { + it('does NOT send custom user-agent header when sendLDHeaders is false', async () => { const config = Object.assign({}, defaultConfig, { useReport: true, sendLDHeaders: false }); const requestor = Requestor(platform, config, env, logger); - requestor.fetchFlagSettings(user, 'hash1', sinon.spy()); + await requestor.fetchFlagSettings(user, 'hash1'); expect(server.requests.length).toEqual(1); expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual(undefined); }); + it('returns parsed JSON response on success', async () => { + const requestor = Requestor(platform, defaultConfig, env, logger); + + const data = { foo: 'bar' }; + server.respondWith(jsonResponse(data)); + + const result = await requestor.fetchFlagSettings(user); + expect(result).toEqual(data); + }); + + it('signals specific error for 404 response', async () => { + const requestor = Requestor(platform, defaultConfig, env, logger); + + server.respondWith(errorResponse(404)); + + const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); + + it('signals general error for non-404 error status', async () => { + const requestor = Requestor(platform, defaultConfig, env, logger); + + server.respondWith(errorResponse(500)); + + const err = new errors.LDFlagFetchError(messages.errorFetchingFlags('500')); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); + + it('signals general error for network error', async () => { + const requestor = Requestor(platform, defaultConfig, env, logger); + + server.respondWith(req => req.error()); + + const err = new errors.LDFlagFetchError(messages.networkError(new Error())); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); + + it('coalesces multiple requests so all callers get the latest result', async () => { + const requestor = Requestor(platform, defaultConfig, env, logger); + + let n = 0; + server.autoRespond = false; + server.respondWith(req => { + n++; + req.respond(...jsonResponse({ value: n })); + }); + + const r1 = requestor.fetchFlagSettings(user); + const r2 = requestor.fetchFlagSettings(user); + + server.respond(); + server.respond(); + // Note that we should only get a single response, { value: 1 } - Sinon does not call our respondWith + // function for the first request, because it's already been cancelled by the time the server looks + // at the request queue. The important thing is just that both requests get the same value. + + const result1 = await r1; + const result2 = await r2; + + expect(result1).toEqual({ value: n }); + expect(result2).toEqual({ value: n }); + }); + describe('When HTTP requests are not available at all', () => { - it('should fail on fetchFlagSettings', done => { + it('fails on fetchFlagSettings', async () => { const requestor = Requestor(stubPlatform.withoutHttp(), defaultConfig, env, logger); - requestor.fetchFlagSettings(user, null, err => { - expect(err.message).toEqual(messages.httpUnavailable()); - done(); - }); + await expect(requestor.fetchFlagSettings(user, null)).rejects.toThrow(messages.httpUnavailable()); }); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/Store-test.js b/packages/ldclient-js-common/src/__tests__/Store-test.js index 67d26220..55bb0620 100644 --- a/packages/ldclient-js-common/src/__tests__/Store-test.js +++ b/packages/ldclient-js-common/src/__tests__/Store-test.js @@ -1,4 +1,5 @@ import * as stubPlatform from './stubPlatform'; + import * as messages from '../messages'; import Identity from '../Identity'; import Store from '../Store'; @@ -10,22 +11,20 @@ describe('Store', () => { const env = 'ENVIRONMENT'; const lsKey = 'ld:' + env + ':' + utils.btoa(JSON.stringify(user)); - it('stores flags', done => { + it('stores flags', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); const flags = { flagKey: { value: 'x' } }; - store.saveFlags(flags, err => { - expect(err).toBe(null); - const value = platform.testing.getLocalStorageImmediately(lsKey); - const expected = Object.assign({ $schema: 1 }, flags); - expect(JSON.parse(value)).toEqual(expected); - done(); - }); + await store.saveFlags(flags); + + const value = platform.testing.getLocalStorageImmediately(lsKey); + const expected = Object.assign({ $schema: 1 }, flags); + expect(JSON.parse(value)).toEqual(expected); }); - it('retrieves and parses flags', done => { + it('retrieves and parses flags', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); @@ -33,14 +32,11 @@ describe('Store', () => { const stored = Object.assign({ $schema: 1 }, expected); platform.testing.setLocalStorageImmediately(lsKey, JSON.stringify(stored)); - store.loadFlags((err, values) => { - expect(err).toBe(null); - expect(values).toEqual(expected); - done(); - }); + const values = await store.loadFlags(); + expect(values).toEqual(expected); }); - it('converts flags from old format if schema property is missing', done => { + it('converts flags from old format if schema property is missing', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); @@ -48,80 +44,59 @@ describe('Store', () => { const newFlags = { flagKey: { value: 'x', version: 0 } }; platform.testing.setLocalStorageImmediately(lsKey, JSON.stringify(oldFlags)); - store.loadFlags((err, values) => { - expect(err).toBe(null); - expect(values).toEqual(newFlags); - done(); - }); + const values = await store.loadFlags(); + expect(values).toEqual(newFlags); }); - it('returns null if storage is empty', done => { + it('returns null if storage is empty', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); - store.loadFlags((err, values) => { - expect(err).toBe(null); - expect(values).toBe(null); - done(); - }); + const values = await store.loadFlags(); + expect(values).toBe(null); }); - it('clears storage and returns null if value is not valid JSON', done => { + it('clears storage and returns null if value is not valid JSON', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); platform.testing.setLocalStorageImmediately(lsKey, '{bad'); - store.loadFlags((err, values) => { - expect(err).not.toBe(null); - expect(values).toBe(null); - expect(platform.testing.getLocalStorageImmediately(lsKey)).toBe(undefined); - done(); - }); + await expect(store.loadFlags()).rejects.toThrow(); + + expect(platform.testing.getLocalStorageImmediately(lsKey)).toBe(undefined); }); - it('uses hash, if present, instead of user properties', done => { + it('uses hash, if present, instead of user properties', async () => { const platform = stubPlatform.defaults(); const hash = '12345'; const keyWithHash = 'ld:' + env + ':' + hash; const store = Store(platform.localStorage, env, hash, ident, platform.testing.logger); const flags = { flagKey: { value: 'x' } }; - store.saveFlags(flags, err => { - expect(err).toBe(null); - const value = platform.testing.getLocalStorageImmediately(keyWithHash); - expect(JSON.parse(value)).toEqual(Object.assign({ $schema: 1 }, flags)); - done(); - }); + await store.saveFlags(flags); + + const value = platform.testing.getLocalStorageImmediately(keyWithHash); + expect(JSON.parse(value)).toEqual(Object.assign({ $schema: 1 }, flags)); }); - it('should handle localStorage.get returning an error', done => { + it('should handle localStorage.get returning an error', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); const myError = new Error('localstorage getitem error'); - jest.spyOn(platform.localStorage, 'get').mockImplementation((key, callback) => { - callback(myError); - }); - - store.loadFlags(err => { - expect(err).toEqual(myError); - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); - done(); - }); + jest.spyOn(platform.localStorage, 'get').mockImplementation(() => Promise.reject(myError)); + + await expect(store.loadFlags()).rejects.toThrow(myError); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); }); - it('should handle localStorage.set returning an error', done => { + it('should handle localStorage.set returning an error', async () => { const platform = stubPlatform.defaults(); const store = Store(platform.localStorage, env, '', ident, platform.testing.logger); const myError = new Error('localstorage setitem error'); - jest.spyOn(platform.localStorage, 'set').mockImplementation((key, value, callback) => { - callback(myError); - }); - - store.saveFlags({ foo: {} }, err => { - expect(err).toEqual(myError); - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); - done(); - }); + jest.spyOn(platform.localStorage, 'set').mockImplementation(() => Promise.reject(myError)); + + await expect(store.saveFlags({ foo: {} })).rejects.toThrow(myError); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); }); }); diff --git a/packages/ldclient-js-common/src/__tests__/UserValidator-test.js b/packages/ldclient-js-common/src/__tests__/UserValidator-test.js new file mode 100644 index 00000000..5dae70c0 --- /dev/null +++ b/packages/ldclient-js-common/src/__tests__/UserValidator-test.js @@ -0,0 +1,57 @@ +import UserValidator from '../UserValidator'; + +describe('UserValidator', () => { + let localStorage; + let logger; + let uv; + + beforeEach(() => { + localStorage = {}; + logger = { + warn: jest.fn(), + }; + uv = UserValidator(localStorage, logger); + }); + + it('rejects null user', async () => { + await expect(uv.validateUser(null)).rejects.toThrow(); + }); + + it('leaves user with string key unchanged', async () => { + const u = { key: 'someone', name: 'me' }; + expect(await uv.validateUser(u)).toEqual(u); + }); + + it('stringifies non-string key', async () => { + const u0 = { key: 123, name: 'me' }; + const u1 = { key: '123', name: 'me' }; + expect(await uv.validateUser(u0)).toEqual(u1); + }); + + it('uses cached key for anonymous user', async () => { + const cachedKey = 'thing'; + let storageKey; + localStorage.get = async key => { + storageKey = key; + return cachedKey; + }; + const u = { anonymous: true }; + expect(await uv.validateUser(u)).toEqual({ key: cachedKey, anonymous: true }); + expect(storageKey).toEqual('ld:$anonUserId'); + }); + + it('generates and stores key for anonymous user', async () => { + let storageKey; + let storedValue; + localStorage.get = async () => null; + localStorage.set = async (key, value) => { + storageKey = key; + storedValue = value; + }; + const u0 = { anonymous: true }; + const u1 = await uv.validateUser(u0); + expect(storedValue).toEqual(expect.anything()); + expect(u1).toEqual({ key: storedValue, anonymous: true }); + expect(storageKey).toEqual('ld:$anonUserId'); + }); +}); diff --git a/packages/ldclient-js-common/src/__tests__/promiseCoalescer-test.js b/packages/ldclient-js-common/src/__tests__/promiseCoalescer-test.js new file mode 100644 index 00000000..0ce16f6e --- /dev/null +++ b/packages/ldclient-js-common/src/__tests__/promiseCoalescer-test.js @@ -0,0 +1,128 @@ +import promiseCoalescer from '../promiseCoalescer'; + +describe('promiseCoalescer', () => { + function instrumentedPromise() { + let resolveFn, rejectFn; + const p = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + p.resolve = resolveFn; + p.reject = rejectFn; + return p; + } + + describe('with a single promise', () => { + it('resolves', async () => { + const c = promiseCoalescer(); + const p = instrumentedPromise(); + c.addPromise(p); + p.resolve(3); + const result = await c.resultPromise; + expect(result).toEqual(3); + }); + + it('rejects', async () => { + const c = promiseCoalescer(); + const p = instrumentedPromise(); + c.addPromise(p); + p.reject(new Error('no')); + await expect(c.resultPromise).rejects.toThrow('no'); + }); + + it('does not call cancelFn', async () => { + const fn = jest.fn(); + const c = promiseCoalescer(); + const p = instrumentedPromise(); + c.addPromise(p, fn); + p.resolve(3); + await c.resultPromise; + expect(fn).not.toHaveBeenCalled(); + }); + + it('calls finallyFn', async () => { + const fn = jest.fn(); + const c = promiseCoalescer(fn); + const p = instrumentedPromise(); + c.addPromise(p, fn); + expect(fn).not.toHaveBeenCalled(); + p.resolve(3); + await c.resultPromise; + expect(fn).toHaveBeenCalledTimes(1); + }); + }); + + describe('with multiple promises', () => { + it('resolves only from the last one', async () => { + const c = promiseCoalescer(); + const p1 = instrumentedPromise(); + const p2 = instrumentedPromise(); + const p3 = instrumentedPromise(); + c.addPromise(p1); + c.addPromise(p2); + c.addPromise(p3); + p2.resolve(2); + p3.resolve(3); + p1.resolve(1); + const result = await c.resultPromise; + expect(result).toEqual(3); + }); + + it('rejects only from the last one', async () => { + const c = promiseCoalescer(); + const p1 = instrumentedPromise(); + const p2 = instrumentedPromise(); + const p3 = instrumentedPromise(); + c.addPromise(p1); + c.addPromise(p2); + c.addPromise(p3); + p2.resolve(2); + p3.reject(new Error('no')); + p1.resolve(new Error('maybe')); + await expect(c.resultPromise).rejects.toThrow('no'); + }); + + it('calls cancelFn on all but the last', async () => { + const fn1 = jest.fn(); + const fn2 = jest.fn(); + const fn3 = jest.fn(); + const c = promiseCoalescer(); + const p1 = instrumentedPromise(); + const p2 = instrumentedPromise(); + const p3 = instrumentedPromise(); + c.addPromise(p1, fn1); + expect(fn1).not.toHaveBeenCalled(); + c.addPromise(p2, fn2); + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).not.toHaveBeenCalled(); + c.addPromise(p3, fn3); + expect(fn1).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + expect(fn3).not.toHaveBeenCalled(); + p2.resolve(2); + p3.resolve(3); + p1.resolve(1); + await c.resultPromise; + expect(fn3).not.toHaveBeenCalled(); + }); + + it('calls finallyFn', async () => { + const fn = jest.fn(); + const c = promiseCoalescer(fn); + const p1 = instrumentedPromise(); + const p2 = instrumentedPromise(); + const p3 = instrumentedPromise(); + c.addPromise(p1); + expect(fn).not.toHaveBeenCalled(); + c.addPromise(p2); + expect(fn).not.toHaveBeenCalled(); + c.addPromise(p3); + expect(fn).not.toHaveBeenCalled(); + p2.resolve(2); + p3.resolve(3); + p1.resolve(1); + await c.resultPromise; + expect(fn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/ldclient-js-common/src/__tests__/stubPlatform.js b/packages/ldclient-js-common/src/__tests__/stubPlatform.js index 8c74345c..a9dba2d1 100644 --- a/packages/ldclient-js-common/src/__tests__/stubPlatform.js +++ b/packages/ldclient-js-common/src/__tests__/stubPlatform.js @@ -6,13 +6,34 @@ import EventEmitter from '../EventEmitter'; const sinonXhr = sinon.useFakeXMLHttpRequest(); sinonXhr.restore(); +// This file provides a stub implementation of the internal platform API for use in tests. +// +// The SDK expects the platform object to have the following properties and methods: +// +// httpRequest?: (method, url, headers, body, sync) => requestProperties +// requestProperties.promise: Promise // resolves to { status, header: (name) => value, body } or rejects for a network error +// requestProperties.cancel?: () => void // provided if it's possible to cancel requests in this implementation +// httpAllowsPost: boolean // true if we can do cross-origin POST requests +// getCurrentUrl: () => string // returns null if we're not in a browser +// isDoNotTrack: () => boolean +// localStorage: { +// get: (key: string, callback: (err: Error, data: string) => void) => void +// set: (key: string, data: string, callback: (err: Error) => void) => void +// clear: (key: string, callback: (err: Error) => void) => void +// } +// eventSourceFactory?: (url: string, options: object) => EventSource +// // note that the options are ignored by the browser's built-in EventSource; they only work with polyfills +// eventSourceIsActive?: (es: EventSource) => boolean // returns true if it's open or connecting +// eventSourceAllowsReport?: boolean // returns true if we can set { method: 'REPORT' } in the options +// userAgent: string + export function defaults() { const localStore = {}; let currentUrl = null; let doNotTrack = false; const p = { - newHttpRequest: () => new sinonXhr(), + httpRequest: newHttpRequest, httpAllowsPost: () => true, httpAllowsSync: () => true, getCurrentUrl: () => currentUrl, @@ -24,19 +45,20 @@ export function defaults() { }, eventSourceIsActive: es => es.readyState === EventSource.OPEN || es.readyState === EventSource.CONNECTING, localStorage: { - get: (key, callback) => { - setTimeout(() => { - callback(null, localStore[key]); - }, 0); - }, - set: (key, value, callback) => { - localStore[key] = value; - setTimeout(() => callback(null), 0); - }, - clear: (key, callback) => { - delete localStore[key]; - setTimeout(() => callback(null), 0); - }, + get: key => + new Promise(resolve => { + resolve(localStore[key]); + }), + set: (key, value) => + new Promise(resolve => { + localStore[key] = value; + resolve(); + }), + clear: key => + new Promise(resolve => { + delete localStore[key]; + resolve(); + }), }, userAgent: 'stubClient', @@ -69,7 +91,7 @@ export function defaults() { export function withoutHttp() { const e = defaults(); - delete e.newHttpRequest; + delete e.httpRequest; return e; } @@ -90,3 +112,52 @@ export function mockStateProvider(initialState) { sp.getInitialState = () => initialState; return sp; } + +// This HTTP implementation is basically the same one that's used in the browser client, but it's +// made to interact with Sinon, so that the tests can use the familiar Sinon API. +// +// It'd be nice to be able to reuse this same logic in the browser client instead of copying it, +// but it's not of any use in Node or Electron so it doesn't really belong in the common package. + +function newHttpRequest(method, url, headers, body, synchronous) { + const xhr = new sinonXhr(); + xhr.open(method, url, !synchronous); + for (const key in headers || {}) { + if (headers.hasOwnProperty(key)) { + xhr.setRequestHeader(key, headers[key]); + } + } + if (synchronous) { + const p = new Promise(resolve => { + xhr.send(body); + resolve(); + }); + return { promise: p }; + } else { + let cancelled; + const p = new Promise((resolve, reject) => { + xhr.addEventListener('load', () => { + if (cancelled) { + return; + } + resolve({ + status: xhr.status, + header: key => xhr.getResponseHeader(key), + body: xhr.responseText, + }); + }); + xhr.addEventListener('error', () => { + if (cancelled) { + return; + } + reject(new Error()); + }); + xhr.send(body); + }); + const cancel = () => { + cancelled = true; + xhr.abort(); + }; + return { promise: p, cancel: cancel }; + } +} diff --git a/packages/ldclient-js-common/src/__tests__/testUtils.js b/packages/ldclient-js-common/src/__tests__/testUtils.js new file mode 100644 index 00000000..27e6a75c --- /dev/null +++ b/packages/ldclient-js-common/src/__tests__/testUtils.js @@ -0,0 +1,83 @@ +import sinon from 'sinon'; + +export function asyncSleep(delay) { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +export function asyncify(f) { + return new Promise(resolve => f(resolve)); +} + +export function errorResponse(status) { + return [status, {}, '']; +} + +export function jsonResponse(data) { + return [200, { 'Content-Type': 'application/json' }, JSON.stringify(data)]; +} + +export function makeDefaultServer() { + const server = sinon.createFakeServer(); + server.autoRespond = true; + server.autoRespondAfter = 0; + server.respondWith(jsonResponse({})); // default 200 response for tests that don't specify otherwise + return server; +} + +export const numericUser = { + key: 1, + secondary: 2, + ip: 3, + country: 4, + email: 5, + firstName: 6, + lastName: 7, + avatar: 8, + name: 9, + anonymous: false, + custom: { age: 99 }, +}; + +// This returns a Promise with a .callback property that is a plain callback function; when +// called, it will resolve the promise with either a single value or an array of arguments. +export function promiseListener() { + let cb; + const p = new Promise(resolve => { + cb = function(value) { + if (arguments.length > 1) { + resolve(Array.prototype.slice.call(arguments)); + } else { + resolve(value); + } + }; + }); + p.callback = cb; + return p; +} + +export const stringifiedNumericUser = { + key: '1', + secondary: '2', + ip: '3', + country: '4', + email: '5', + firstName: '6', + lastName: '7', + avatar: '8', + name: '9', + anonymous: false, + custom: { age: 99 }, +}; + +export function makeBootstrap(flagsData) { + const ret = { $flagsState: {} }; + for (const key in flagsData) { + const state = Object.assign({}, flagsData[key]); + ret[key] = state.value; + delete state.value; + ret.$flagsState[key] = state; + } + return ret; +} diff --git a/packages/ldclient-js-common/src/index.js b/packages/ldclient-js-common/src/index.js index 12cf2e9c..a8fb80a7 100644 --- a/packages/ldclient-js-common/src/index.js +++ b/packages/ldclient-js-common/src/index.js @@ -4,6 +4,7 @@ import Store from './Store'; import Stream from './Stream'; import Requestor from './Requestor'; import Identity from './Identity'; +import UserValidator from './UserValidator'; import * as configuration from './configuration'; import createConsoleLogger from './consoleLogger'; import * as utils from './utils'; @@ -22,6 +23,9 @@ const internalChangeEvent = 'internal-change'; // options: the configuration (after any appropriate defaults have been applied) // If we need to give the platform-specific clients access to any internals here, we should add those // as properties of the return object, not public properties of the client. +// +// For definitions of the API in the platform object, see stubPlatform.js in the test code. + export function initialize(env, user, specifiedOptions, platform, extraDefaults) { const logger = createLogger(); const emitter = EventEmitter(logger); @@ -33,11 +37,13 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) const events = options.eventProcessor || EventProcessor(platform, options, environment, logger, emitter); const requestor = Requestor(platform, options, environment, logger); const seenRequests = {}; - let flags = typeof options.bootstrap === 'object' ? readFlagsFromBootstrap(options.bootstrap) : {}; + let flags = {}; let useLocalStorage; let streamActive; - let streamForcedState; + let streamForcedState = options.streaming; let subscribedToChangeEvents; + let inited = false; + let closed = false; let firstEvent = true; // The "stateProvider" object is used in the Electron SDK, to allow one client instance to take partial @@ -52,6 +58,13 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) // be responsible for delivering it, or false if we still should deliver it ourselves. const stateProvider = options.stateProvider; + const ident = Identity(null, sendIdentifyEvent); + const userValidator = UserValidator(platform.localStorage, logger); + let store; + if (platform.localStorage) { + store = new Store(platform.localStorage, environment, hash, ident, logger); + } + function createLogger() { if (specifiedOptions && specifiedOptions.logger) { return specifiedOptions.logger; @@ -89,7 +102,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } function shouldEnqueueEvent() { - return sendEvents && !platform.isDoNotTrack(); + return sendEvents && !closed && !platform.isDoNotTrack(); } function enqueueEvent(event) { @@ -129,12 +142,6 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } } - const ident = Identity(user, sendIdentifyEvent); - let store; - if (platform.localStorage) { - store = new Store(platform.localStorage, environment, hash, ident, logger); - } - function sendFlagEvent(key, detail, defaultValue) { const user = ident.getUser(); const now = new Date(); @@ -170,42 +177,38 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } function identify(user, hash, onDone) { + if (closed) { + return utils.wrapPromiseCallback(Promise.resolve({}), onDone); + } if (stateProvider) { // We're being controlled by another client instance, so only that instance is allowed to change the user logger.warn(messages.identifyDisabled()); return utils.wrapPromiseCallback(Promise.resolve(utils.transformVersionedValuesToValues(flags)), onDone); } - const clearFirst = new Promise(resolve => (useLocalStorage && store ? store.clearFlags(resolve) : resolve())); + const clearFirst = useLocalStorage && store ? store.clearFlags() : Promise.resolve(); return utils.wrapPromiseCallback( - clearFirst.then( - () => - new Promise((resolve, reject) => { - if (!user || user.key === null || user.key === undefined) { - const err = new errors.LDInvalidUserError(user ? messages.invalidUser() : messages.userNotSpecified()); - emitter.maybeReportError(err); - reject(err); - } else { - ident.setUser(user); - requestor.fetchFlagSettings(ident.getUser(), hash, (err, settings) => { - if (err) { - emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); - return reject(err); - } - const result = utils.transformVersionedValuesToValues(settings); - if (settings) { - updateSettings(settings, () => { - resolve(result); - }); - } else { - resolve(result); - } - if (streamActive) { - connectStream(); - } - }); - } - }) - ), + clearFirst + .then(() => userValidator.validateUser(user)) + .then(realUser => ident.setUser(realUser)) + .then(() => requestor.fetchFlagSettings(ident.getUser(), hash)) + .then(requestedFlags => { + const flagValueMap = utils.transformVersionedValuesToValues(requestedFlags); + if (requestedFlags) { + return replaceAllFlags(requestedFlags).then(() => flagValueMap); + } else { + return flagValueMap; + } + }) + .then(flagValueMap => { + if (streamActive) { + connectStream(); + } + return flagValueMap; + }) + .catch(err => { + emitter.maybeReportError(err); + return Promise.reject(err); + }), onDone ); } @@ -215,9 +218,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } function flush(onDone) { - return utils.wrapPromiseCallback( - new Promise(resolve => (sendEvents ? resolve(events.flush()) : resolve()), onDone) - ); + return utils.wrapPromiseCallback(sendEvents ? events.flush() : Promise.resolve(), onDone); } function variation(key, defaultValue) { @@ -307,13 +308,13 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) if (err) { emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); } - updateSettings(settings); + replaceAllFlags(settings); // don't wait for this Promise to be resolved }); }, put: function(e) { const data = JSON.parse(e.data); logger.debug(messages.debugStreamPut()); - updateSettings(data); + replaceAllFlags(data); // don't wait for this Promise to be resolved }, patch: function(e) { const data = JSON.parse(e.data); @@ -333,7 +334,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } else { mods[data.key] = { current: newDetail }; } - postProcessSettingsUpdate(mods); + handleFlagChanges(mods); // don't wait for this Promise to be resolved } else { logger.debug(messages.debugStreamPatchIgnored(data.key)); } @@ -347,7 +348,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) mods[data.key] = { previous: flags[data.key].value }; } flags[data.key] = { version: data.version, deleted: true }; - postProcessSettingsUpdate(mods); + handleFlagChanges(mods); // don't wait for this Promise to be resolved } else { logger.debug(messages.debugStreamDeleteIgnored(data.key)); } @@ -362,11 +363,14 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } } - function updateSettings(newFlags, callback) { + // Returns a Promise which will be resolved when we have completely updated the internal flags state, + // dispatched all change events, and updated local storage if appropriate. This Promise is guaranteed + // never to have an unhandled rejection. + function replaceAllFlags(newFlags) { const changes = {}; if (!newFlags) { - return; + return Promise.resolve(); } for (const key in flags) { @@ -385,10 +389,12 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } flags = newFlags; - postProcessSettingsUpdate(changes, callback); + return handleFlagChanges(changes).catch(() => {}); // swallow any exceptions from this Promise } - function postProcessSettingsUpdate(changes, callback) { + // Returns a Promise which will be resolved when we have dispatched all change events and updated + // local storage if appropriate. + function handleFlagChanges(changes) { const keys = Object.keys(changes); if (keys.length > 0) { @@ -417,17 +423,17 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } if (useLocalStorage && store) { - store.saveFlags(flags, callback); + return store.saveFlags(flags).catch(() => null); // disregard errors } else { - callback && callback(); + return Promise.resolve(); } } function on(event, handler, context) { if (isChangeEventKey(event)) { subscribedToChangeEvents = true; - if (!streamActive && streamForcedState === undefined) { - connectStream(); + if (inited) { + updateStreamingState(); } emitter.on(event, handler, context); } else { @@ -457,12 +463,16 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) const newState = state === null ? undefined : state; if (newState !== streamForcedState) { streamForcedState = newState; - const shouldBeStreaming = streamForcedState || (subscribedToChangeEvents && streamForcedState === undefined); - if (shouldBeStreaming && !streamActive) { - connectStream(); - } else if (!shouldBeStreaming && streamActive) { - disconnectStream(); - } + updateStreamingState(); + } + } + + function updateStreamingState() { + const shouldBeStreaming = streamForcedState || (subscribedToChangeEvents && streamForcedState === undefined); + if (shouldBeStreaming && !streamActive) { + connectStream(); + } else if (!shouldBeStreaming && streamActive) { + disconnectStream(); } } @@ -488,6 +498,14 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) }); }); + if (typeof options.bootstrap === 'string' && options.bootstrap.toUpperCase() === 'LOCALSTORAGE') { + if (store) { + useLocalStorage = true; + } else { + logger.warn(messages.localStorageUnavailable()); + } + } + if (stateProvider) { // The stateProvider option is used in the Electron SDK, to allow a client instance in the main process // to control another client instance (i.e. this one) in the renderer process. We can't predict which @@ -501,78 +519,68 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } stateProvider.on('update', updateFromStateProvider); } else { - if (!env) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidEnvironmentIdError(messages.environmentNotSpecified())); - }); - } - - if (!user) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidUserError(messages.userNotSpecified())); - }); - } else if (!user.key) { - utils.onNextTick(() => { - emitter.maybeReportError(new errors.LDInvalidUserError(messages.invalidUser())); - }); - } - } - - if (typeof options.bootstrap === 'string' && options.bootstrap.toUpperCase() === 'LOCALSTORAGE') { - if (store) { - useLocalStorage = true; - } else { - logger.warn(messages.localStorageUnavailable()); - } + finishInit().catch(err => emitter.maybeReportError(err)); } - if (typeof options.bootstrap === 'object') { - utils.onNextTick(signalSuccessfulInit); - } else if (useLocalStorage) { - store.loadFlags((err, storedFlags) => { - if (storedFlags === null || storedFlags === undefined) { - flags = {}; - requestor.fetchFlagSettings(ident.getUser(), hash, (err, requestedFlags) => { - if (err) { - const initErr = new errors.LDFlagFetchError(messages.errorFetchingFlags(err)); - signalFailedInit(initErr); - } else { - if (requestedFlags) { - updateSettings(requestedFlags, () => {}); // this includes saving to local storage and sending change events - } else { - flags = {}; - } - signalSuccessfulInit(); - } - }); + function finishInit() { + if (!env) { + return Promise.reject(new errors.LDInvalidEnvironmentIdError(messages.environmentNotSpecified())); + } + return userValidator.validateUser(user).then(realUser => { + ident.setUser(realUser); + if (typeof options.bootstrap === 'object') { + flags = readFlagsFromBootstrap(options.bootstrap); + return signalSuccessfulInit(); + } else if (useLocalStorage) { + return finishInitWithLocalStorage(); } else { - // We're reading the flags from local storage. Signal that we're ready, - // then update localStorage for the next page load. We won't signal changes or update - // the in-memory flags unless you subscribe for changes - flags = storedFlags; - utils.onNextTick(signalSuccessfulInit); - - requestor.fetchFlagSettings(ident.getUser(), hash, (err, requestedFlags) => { - if (err) { - emitter.maybeReportError(new errors.LDFlagFetchError(messages.errorFetchingFlags(err))); - } - if (requestedFlags) { - updateSettings(requestedFlags, () => {}); // this includes saving to local storage and sending change events - } - }); + return finishInitWithPolling(); } }); - } else if (!stateProvider) { - requestor.fetchFlagSettings(ident.getUser(), hash, (err, requestedFlags) => { - if (err) { - flags = {}; - const initErr = new errors.LDFlagFetchError(messages.errorFetchingFlags(err)); - signalFailedInit(initErr); - } else { + } + + function finishInitWithLocalStorage() { + return store + .loadFlags() + .catch(() => null) // treat an error the same as if no flags were available + .then(storedFlags => { + if (storedFlags === null || storedFlags === undefined) { + flags = {}; + return requestor + .fetchFlagSettings(ident.getUser(), hash) + .then(requestedFlags => replaceAllFlags(requestedFlags || {})) + .then(signalSuccessfulInit) + .catch(err => { + const initErr = new errors.LDFlagFetchError(messages.errorFetchingFlags(err)); + signalFailedInit(initErr); + }); + } else { + // We're reading the flags from local storage. Signal that we're ready, + // then update localStorage for the next page load. We won't signal changes or update + // the in-memory flags unless you subscribe for changes + flags = storedFlags; + utils.onNextTick(signalSuccessfulInit); + + return requestor + .fetchFlagSettings(ident.getUser(), hash) + .then(requestedFlags => replaceAllFlags(requestedFlags)) + .catch(err => emitter.maybeReportError(err)); + } + }); + } + + function finishInitWithPolling() { + return requestor + .fetchFlagSettings(ident.getUser(), hash) + .then(requestedFlags => { flags = requestedFlags || {}; + // Note, we don't need to call updateSettings here because local storage and change events are not relevant signalSuccessfulInit(); - } - }); + }) + .catch(err => { + flags = {}; + signalFailedInit(err); + }); } function initFromStateProvider(state) { @@ -587,15 +595,14 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) ident.setUser(state.user); } if (state.flags) { - updateSettings(state.flags); + replaceAllFlags(state.flags); // don't wait for this Promise to be resolved } } function signalSuccessfulInit() { logger.info(messages.clientInitialized()); - if (options.streaming !== undefined) { - setStreaming(options.streaming); - } + inited = true; + updateStreamingState(); emitter.emit(readyEvent); emitter.emit(successEvent); // allows initPromise to distinguish between success and failure } @@ -612,11 +619,24 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) } } - function stop() { - if (sendEvents) { - events.stop(); - events.flush(true); + function close(onDone) { + if (closed) { + return utils.wrapPromiseCallback(Promise.resolve(), onDone); } + const p = Promise.resolve() + .then(() => { + disconnectStream(); + if (sendEvents) { + events.stop(); + return events.flush(); + } + }) + .catch(() => {}) + .finally(() => { + closed = true; + flags = {}; + }); + return utils.wrapPromiseCallback(p, onDone); } function getFlagsInternal() { @@ -637,6 +657,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) setStreaming: setStreaming, flush: flush, allFlags: allFlags, + close: close, }; return { @@ -647,7 +668,6 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) logger: logger, // The logging abstraction. requestor: requestor, // The Requestor object. start: start, // Starts the client once the environment is ready. - stop: stop, // Shuts down the client. enqueueEvent: enqueueEvent, // Puts an analytics event in the queue, if event sending is enabled. getFlagsInternal: getFlagsInternal, // Returns flag data structure with all details. internalChangeEventName: internalChangeEvent, // This event is triggered whenever we have new flag state. diff --git a/packages/ldclient-js-common/src/messages.js b/packages/ldclient-js-common/src/messages.js index 071f0da3..973a206d 100644 --- a/packages/ldclient-js-common/src/messages.js +++ b/packages/ldclient-js-common/src/messages.js @@ -23,6 +23,12 @@ export const localStorageUnavailable = function() { return 'localStorage is unavailable'; }; +export const localStorageUnavailableForUserId = function() { + return 'localStorage is unavailable, so anonymous user ID cannot be cached'; +}; + +export const networkError = e => 'network error' + (e ? ' (' + e + ')' : ''); + export const unknownCustomEventKey = function(key) { return 'Custom event "' + key + '" does not exist'; }; diff --git a/packages/ldclient-js-common/src/promiseCoalescer.js b/packages/ldclient-js-common/src/promiseCoalescer.js new file mode 100644 index 00000000..1c02e85c --- /dev/null +++ b/packages/ldclient-js-common/src/promiseCoalescer.js @@ -0,0 +1,50 @@ +// This function allows a series of Promises to be coalesced such that only the most recently +// added one actually matters. For instance, if several HTTP requests are made to the same +// endpoint and we want to ensure that whoever made each one always gets the latest data, each +// can be passed to addPromise (on the same coalescer) and each caller can wait on the +// coalescer.resultPromise; all three will then receive the result (or error) from the *last* +// request, and the results of the first two will be discarded. +// +// The cancelFn callback, if present, will be called whenever an existing promise is being +// discarded. This can be used for instance to abort an HTTP request that's now obsolete. +// +// The finallyFn callback, if present, is called on completion of the whole thing. This is +// different from calling coalescer.resultPromise.finally() because it is executed before any +// other handlers. Its purpose is to tell the caller that this coalescer should no longer be used. + +export default function promiseCoalescer(finallyFn) { + let currentPromise; + let currentCancelFn; + let finalResolve; + let finalReject; + + const coalescer = {}; + + coalescer.addPromise = (p, cancelFn) => { + currentPromise = p; + currentCancelFn && currentCancelFn(); + currentCancelFn = cancelFn; + + p.then( + result => { + if (currentPromise === p) { + finalResolve(result); + finallyFn && finallyFn(); + } + }, + error => { + if (currentPromise === p) { + finalReject(error); + finallyFn && finallyFn(); + } + } + ); + }; + + coalescer.resultPromise = new Promise((resolve, reject) => { + finalResolve = resolve; + finalReject = reject; + }); + + return coalescer; +} diff --git a/packages/ldclient-js-common/src/utils.js b/packages/ldclient-js-common/src/utils.js index 0f8b8772..eb4a8a34 100644 --- a/packages/ldclient-js-common/src/utils.js +++ b/packages/ldclient-js-common/src/utils.js @@ -1,6 +1,8 @@ import * as base64 from 'base64-js'; import fastDeepEqual from 'fast-deep-equal'; +const userAttrsToStringify = ['key', 'secondary', 'ip', 'country', 'email', 'firstName', 'lastName', 'avatar', 'name']; + // See http://ecmanaut.blogspot.com/2006/07/encoding-decoding-utf8-in-javascript.html export function btoa(s) { const escaped = unescape(encodeURIComponent(s)); @@ -147,10 +149,28 @@ export function getLDUserAgentString(platform) { return platform.userAgent + '/' + VERSION; } -export function addLDHeaders(xhr, platform) { - xhr.setRequestHeader('X-LaunchDarkly-User-Agent', getLDUserAgentString(platform)); +export function getLDHeaders(platform) { + return { + 'X-LaunchDarkly-User-Agent': getLDUserAgentString(platform), + }; } export function extend(...objects) { return objects.reduce((acc, obj) => ({ ...acc, ...obj }), {}); } + +export function sanitizeUser(user) { + if (!user) { + return user; + } + let newUser; + for (const i in userAttrsToStringify) { + const attr = userAttrsToStringify[i]; + const value = user[attr]; + if (value !== undefined && typeof value !== 'string') { + newUser = newUser || Object.assign({}, user); + newUser[attr] = String(value); + } + } + return newUser || user; +} diff --git a/packages/ldclient-js-common/test-types.ts b/packages/ldclient-js-common/test-types.ts index d3c849fe..1657c0ac 100644 --- a/packages/ldclient-js-common/test-types.ts +++ b/packages/ldclient-js-common/test-types.ts @@ -8,6 +8,7 @@ var ver: string = ld.version; var logger: ld.LDLogger = ld.createConsoleLogger("info"); var userWithKeyOnly: ld.LDUser = { key: 'user' }; +var anonUserWithNoKey: ld.LDUser = { anonymous: true }; var user: ld.LDUser = { key: 'user', secondary: 'otherkey', diff --git a/packages/ldclient-js-common/typings.d.ts b/packages/ldclient-js-common/typings.d.ts index 4a0fad81..c934cfd7 100644 --- a/packages/ldclient-js-common/typings.d.ts +++ b/packages/ldclient-js-common/typings.d.ts @@ -215,8 +215,15 @@ declare module 'ldclient-js-common' { export interface LDUser { /** * A unique string identifying a user. + * + * If you omit this property, and also set `anonymous` to `true`, the SDK will generate a UUID string + * and use that as the key; it will attempt to persist that value in local storage if possible so the + * next anonymous user will get the same key, but if local storage is unavailable then it will + * generate a new key each time you specify the user. + * + * It is an error to omit the `key` property if `anonymous` is not set. */ - key: string; + key?: string; /** * An optional secondary key for a user. This affects @@ -570,6 +577,20 @@ declare module 'ldclient-js-common' { * [[variation]], so any flag that cannot be evaluated will have a null value. */ allFlags(): LDFlagSet; - } + /** + * Shuts down the client and releases its resources, after delivering any pending analytics + * events. After the client is closed, all calls to [[variation]] will return default values, + * and it will not make any requests to LaunchDarkly. + * + * @param onDone + * A function which will be called when the operation completes. If omitted, you + * will receive a Promise instead. + * + * @returns + * If you provided a callback, then nothing. Otherwise, a Promise which resolves once + * closing is finished. It will never be rejected. + */ + close(onDone?: () => void): Promise; + } } diff --git a/packages/ldclient-js/src/GoalManager.js b/packages/ldclient-js/src/GoalManager.js index 38aa9145..cb17a482 100644 --- a/packages/ldclient-js/src/GoalManager.js +++ b/packages/ldclient-js/src/GoalManager.js @@ -76,19 +76,21 @@ export default function GoalManager(clientVars, readyCallback) { } } - clientVars.requestor.fetchGoals((err, g) => { - if (err) { + clientVars.requestor + .fetchGoals() + .then(g => { + if (g && g.length > 0) { + goals = g; + goalTracker = GoalTracker(goals, sendGoalEvent); + watchLocation(locationWatcherInterval, refreshGoalTracker); + } + }) + .catch(err => { clientVars.emitter.maybeReportError( - new common.errors.LDUnexpectedResponseError('Error fetching goals: ' + err.message ? err.message : err) + new common.errors.LDUnexpectedResponseError('Error fetching goals: ' + (err && err.message) ? err.message : err) ); - } - if (g && g.length > 0) { - goals = g; - goalTracker = GoalTracker(goals, sendGoalEvent); - watchLocation(locationWatcherInterval, refreshGoalTracker); - } - readyCallback(); - }); + }) + .finally(readyCallback); return ret; } diff --git a/packages/ldclient-js/src/__tests__/LDClient-test.js b/packages/ldclient-js/src/__tests__/LDClient-test.js index 8f6a70b5..4309359d 100644 --- a/packages/ldclient-js/src/__tests__/LDClient-test.js +++ b/packages/ldclient-js/src/__tests__/LDClient-test.js @@ -8,22 +8,20 @@ describe('LDClient', () => { const user = { key: 'user' }; let warnSpy; let errorSpy; - let xhr; - let requests = []; + let server; beforeEach(() => { - xhr = sinon.useFakeXMLHttpRequest(); - xhr.onCreate = function(req) { - requests.push(req); - }; + server = sinon.createFakeServer(); + server.autoRespond = true; + server.autoRespondAfter = 0; + server.respondWith([200, { 'Content-Type': 'application/json' }, '{}']); // default 200 response for tests that don't specify otherwise warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { - requests = []; - xhr.restore(); + server.restore(); warnSpy.mockRestore(); errorSpy.mockRestore(); }); @@ -33,58 +31,58 @@ describe('LDClient', () => { }); describe('initialization', () => { - it('should trigger the ready event', done => { - const handleReady = jest.fn(); - const client = LDClient.initialize(envName, user, { - bootstrap: {}, - }); - - client.on('ready', handleReady); - - setTimeout(() => { - expect(handleReady).toHaveBeenCalled(); - done(); - }, 0); + it('should trigger the ready event', async () => { + const client = LDClient.initialize(envName, user, { bootstrap: {}, sendEvents: false }); + await client.waitForInitialization(); }); - it('should not fetch flag settings if bootstrap is provided, but should still fetch goals', () => { - LDClient.initialize(envName, user, { bootstrap: {} }); - expect(requests.length).toEqual(1); - expect(/sdk\/eval/.test(requests[0].url)).toEqual(false); // it's the goals request + it('should not fetch flag settings if bootstrap is provided, but should still fetch goals', async () => { + const client = LDClient.initialize(envName, user, { bootstrap: {}, sendEvents: false }); + await client.waitForInitialization(); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].url).toMatch(/sdk\/goals/); }); - it('sends correct User-Agent in request', done => { - LDClient.initialize(envName, user, { fetchGoals: false }); + it('sends correct User-Agent in request', async () => { + const client = LDClient.initialize(envName, user, { fetchGoals: false, sendEvents: false }); + await client.waitForInitialization(); - setTimeout(() => { - expect(requests.length).toEqual(1); - expect(requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toMatch(/^JSClient\//); - done(); - }, 0); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toMatch(/^JSClient\//); }); }); describe('goals', () => { - it('fetches goals if fetchGoals is unspecified', () => { - LDClient.initialize(envName, user, {}); - expect(requests.length).toEqual(2); - expect(/sdk\/goals/.test(requests[1].url)).toEqual(true); + it('fetches goals if fetchGoals is unspecified', async () => { + const client = LDClient.initialize(envName, user, { sendEvents: false }); + await client.waitForInitialization(); + expect(server.requests.length).toEqual(2); + // The following line uses arrayContaining because we can't be sure whether the goals request will + // be made before or after the flags request. + expect(server.requests).toEqual( + expect.arrayContaining([expect.objectContaining({ url: expect.stringMatching(/sdk\/goals/) })]) + ); }); - it('fetches goals if fetchGoals is true', () => { - LDClient.initialize(envName, user, { fetchGoals: true }); - expect(requests.length).toEqual(2); - expect(/sdk\/goals/.test(requests[1].url)).toEqual(true); + it('fetches goals if fetchGoals is true', async () => { + const client = LDClient.initialize(envName, user, { fetchGoals: true, sendEvents: false }); + await client.waitForInitialization(); + expect(server.requests.length).toEqual(2); + expect(server.requests).toEqual( + expect.arrayContaining([expect.objectContaining({ url: expect.stringMatching(/sdk\/goals/) })]) + ); }); - it('does not fetch goals if fetchGoals is false', () => { - LDClient.initialize(envName, user, { fetchGoals: false }); - expect(requests.length).toEqual(1); + it('does not fetch goals if fetchGoals is false', async () => { + const client = LDClient.initialize(envName, user, { fetchGoals: false, sendEvents: false }); + await client.waitForInitialization(); + expect(server.requests.length).toEqual(1); + expect(server.requests[0].url).toMatch(/sdk\/eval/); }); it('should resolve waitUntilGoalsReady when goals are loaded', done => { const handleGoalsReady = jest.fn(); - const client = LDClient.initialize(envName, user, { bootstrap: {} }); + const client = LDClient.initialize(envName, user, { bootstrap: {}, sendEvents: false }); client.waitUntilGoalsReady().then(handleGoalsReady); @@ -95,35 +93,104 @@ describe('LDClient', () => { }, 0); }); - expect(requests.length).toEqual(1); - requests[0].respond(200, { 'Content-Type': 'application/json' }, '[]'); + expect(server.requests.length).toEqual(1); + server.requests[0].respond(200, { 'Content-Type': 'application/json' }, '[]'); }); }); describe('track()', () => { - it('should not warn when tracking a known custom goal event', done => { - const client = LDClient.initialize(envName, user, { bootstrap: {} }); - - client.on('ready', () => { - client.track('known'); - expect(warnSpy).not.toHaveBeenCalled(); - expect(errorSpy).not.toHaveBeenCalled(); - done(); - }); + it('should not warn when tracking a known custom goal event', async () => { + server.respondWith([200, { 'Content-Type': 'application/json' }, '[{"key": "known", "kind": "custom"}]']); - requests[0].respond(200, { 'Content-Type': 'application/json' }, '[{"key": "known", "kind": "custom"}]'); + const client = LDClient.initialize(envName, user, { bootstrap: {}, sendEvents: false }); + await client.waitForInitialization(); + await client.waitUntilGoalsReady(); + + client.track('known'); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); }); - it('should warn when tracking an unknown custom goal event', done => { - const client = LDClient.initialize(envName, user, { bootstrap: {} }); + it('should warn when tracking an unknown custom goal event', async () => { + server.respondWith([200, { 'Content-Type': 'application/json' }, '[{"key": "known", "kind": "custom"}]']); - requests[0].respond(200, { 'Content-Type': 'application/json' }, '[{"key": "known", "kind": "custom"}]'); + const client = LDClient.initialize(envName, user, { bootstrap: {}, sendEvents: false }); + await client.waitForInitialization(); + await client.waitUntilGoalsReady(); - client.on('ready', () => { - client.track('unknown'); - expect(warnSpy).toHaveBeenCalledWith(common.messages.unknownCustomEventKey('unknown')); - done(); - }); + client.track('unknown'); + expect(warnSpy).toHaveBeenCalledWith(common.messages.unknownCustomEventKey('unknown')); + }); + }); + + describe('event flushing', () => { + it('normally uses asynchronous XHR', async () => { + const config = { bootstrap: {}, flushInterval: 100000, fetchGoals: false }; + const client = LDClient.initialize(envName, user, config); + await client.waitForInitialization(); + + await client.flush(); + + expect(server.requests.length).toEqual(2); + expect(server.requests[0].async).toBe(true); // flags query + expect(server.requests[1].async).toBe(true); // events + }); + + async function setupClientAndTriggerUnload() { + const config = { bootstrap: {}, flushInterval: 100000, fetchGoals: false }; + const client = LDClient.initialize(envName, user, config); + await client.waitForInitialization(); + + window.dispatchEvent(new window.Event('beforeunload')); + + return client; + } + + describe('uses synchronous XHR during page unload', () => { + function testWithUserAgent(desc, ua) { + it('in ' + desc, async () => { + window.navigator.__defineGetter__('userAgent', () => ua); + + await setupClientAndTriggerUnload(); + + expect(server.requests.length).toEqual(2); + expect(server.requests[0].async).toBe(true); // flags query + expect(server.requests[1].async).toBe(false); // events + }); + } + + testWithUserAgent( + 'Chrome 72', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36' + ); + + testWithUserAgent('unknown browser', 'Special Kitty Cat Browser'); + + testWithUserAgent('empty user-agent', null); + }); + + describe('discards events during page unload', () => { + function testWithUserAgent(desc, ua) { + it('in ' + desc, async () => { + window.navigator.__defineGetter__('userAgent', () => ua); + + await setupClientAndTriggerUnload(); + + window.dispatchEvent(new window.Event('beforeunload')); + + expect(server.requests.length).toEqual(1); // flags query + }); + } + + testWithUserAgent( + 'Chrome 73', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36' + ); + + testWithUserAgent( + 'Chrome 74', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3683.103 Safari/537.36' + ); }); }); }); diff --git a/packages/ldclient-js/src/__tests__/browserPlatform-test.js b/packages/ldclient-js/src/__tests__/browserPlatform-test.js index 2137b878..27d5a3b9 100644 --- a/packages/ldclient-js/src/__tests__/browserPlatform-test.js +++ b/packages/ldclient-js/src/__tests__/browserPlatform-test.js @@ -1,44 +1,89 @@ +import sinon from 'sinon'; + import browserPlatform from '../browserPlatform'; describe('browserPlatform', () => { const platform = browserPlatform(); const lsKeyPrefix = 'ldclient-js-test:'; - describe('httpAllowsSync()', () => { - function platformWithUserAgent(s) { - window.navigator.__defineGetter__('userAgent', () => s); - return browserPlatform(); - } + describe('httpRequest()', () => { + // These tests verify that our HTTP abstraction is correctly translated into the XMLHttpRequest API, + // which will be intercepted by Sinon. + + const url = 'http://example'; + + let server; - it('returns true for Chrome 72', () => { - const p = platformWithUserAgent( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36' - ); - expect(p.httpAllowsSync()).toBe(true); + beforeEach(() => { + server = sinon.createFakeServer(); }); - it('returns false for Chrome 73', () => { - const p = platformWithUserAgent( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36' - ); - expect(p.httpAllowsSync()).toBe(false); + afterEach(() => { + server.restore(); }); - it('returns false for Chrome 74', () => { - const p = platformWithUserAgent( - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3683.103 Safari/537.36' - ); - expect(p.httpAllowsSync()).toBe(false); + it('sets request properties', () => { + const method = 'POST'; + const headers = { a: '1', b: '2' }; + const body = '{}'; + platform.httpRequest(method, url, headers, body); + + expect(server.requests.length).toEqual(1); + const req = server.requests[0]; + + expect(req.method).toEqual(method); + expect(req.url).toEqual(url); + expect(req.requestHeaders['a']).toEqual('1'); + expect(req.requestHeaders['b']).toEqual('2'); + expect(req.requestBody).toEqual(body); + expect(req.async).toEqual(true); }); - it('returns true for unknown browser', () => { - const p = platformWithUserAgent('Special Kitty Cat Browser'); - expect(p.httpAllowsSync()).toBe(true); + it('resolves promise when response is received', async () => { + const requestInfo = platform.httpRequest('GET', url); + + expect(server.requests.length).toEqual(1); + const req = server.requests[0]; + req.respond(200, {}, 'hello'); + + const result = await requestInfo.promise; + expect(result.status).toEqual(200); + expect(result.body).toEqual('hello'); }); - it('returns true if userAgent is missing', () => { - const p = platformWithUserAgent(null); - expect(p.httpAllowsSync()).toBe(true); + it('returns headers', async () => { + const headers = { 'Content-Type': 'text/plain', Date: 'not really a date' }; + const requestInfo = platform.httpRequest('GET', url); + + expect(server.requests.length).toEqual(1); + const req = server.requests[0]; + req.respond(200, headers, 'hello'); + + const result = await requestInfo.promise; + expect(result.header('content-type')).toEqual(headers['Content-Type']); + expect(result.header('date')).toEqual(headers['Date']); + }); + + it('rejects promise if request gets a network error', async () => { + const requestInfo = platform.httpRequest('GET', url); + + expect(server.requests.length).toEqual(1); + const req = server.requests[0]; + req.error(); + + await expect(requestInfo.promise).rejects.toThrow(); + }); + + it('allows request to be cancelled', () => { + const requestInfo = platform.httpRequest('GET', url); + + expect(server.requests.length).toEqual(1); + expect(server.requests[0].aborted).toBeFalsy(); + + requestInfo.cancel(); + + expect(server.requests.length).toEqual(1); + expect(server.requests[0].aborted).toBe(true); }); }); @@ -87,39 +132,24 @@ describe('browserPlatform', () => { // mock implementation of window.localStorage, but these tests still verify that our async // wrapper code in browserPlatform.js is passing the parameters through correctly. - it('returns null or undefined for missing value', done => { - platform.localStorage.get(lsKeyPrefix + 'unused-key', (err, value) => { - expect(err).not.toBe(expect.anything()); - expect(value).not.toBe(expect.anything()); - done(); - }); + it('returns null or undefined for missing value', async () => { + const value = await platform.localStorage.get(lsKeyPrefix + 'unused-key'); + expect(value).not.toBe(expect.anything()); }); - it('can get and set value', done => { + it('can get and set value', async () => { const key = lsKeyPrefix + 'get-set-key'; - platform.localStorage.set(key, 'hello', err => { - expect(err).not.toBe(expect.anything()); - platform.localStorage.get(key, (err, value) => { - expect(err).not.toBe(expect.anything()); - expect(value).toEqual('hello'); - done(); - }); - }); + await platform.localStorage.set(key, 'hello'); + const value = await platform.localStorage.get(key); + expect(value).toEqual('hello'); }); - it('can delete value', done => { + it('can delete value', async () => { const key = lsKeyPrefix + 'delete-key'; - platform.localStorage.set(key, 'hello', err => { - expect(err).not.toBe(expect.anything()); - platform.localStorage.clear(key, err => { - expect(err).not.toBe(expect.anything()); - platform.localStorage.get(key, (err, value) => { - expect(err).not.toBe(expect.anything()); - expect(value).not.toBe(expect.anything()); - done(); - }); - }); - }); + await platform.localStorage.set(key, 'hello'); + await platform.localStorage.clear(key); + const value = platform.localStorage.get(key); + expect(value).not.toBe(expect.anything()); }); it('reports local storage as being unavailable if window.localStorage is missing', () => { diff --git a/packages/ldclient-js/src/browserPlatform.js b/packages/ldclient-js/src/browserPlatform.js index e79ce395..5f8ee22f 100644 --- a/packages/ldclient-js/src/browserPlatform.js +++ b/packages/ldclient-js/src/browserPlatform.js @@ -1,9 +1,13 @@ +import newHttpRequest from './httpRequest'; + export default function makeBrowserPlatform() { const ret = {}; + ret.pageIsClosing = false; // this will be set to true by index.js if the page is closing + // XMLHttpRequest may not exist if we're running in a server-side rendering context if (window.XMLHttpRequest) { - ret.newHttpRequest = () => new window.XMLHttpRequest(); + ret.httpRequest = (method, url, headers, body) => newHttpRequest(method, url, headers, body, ret.pageIsClosing); } let hasCors; @@ -15,9 +19,6 @@ export default function makeBrowserPlatform() { return hasCors; }; - const allowsSync = isSyncXhrSupported(); - ret.httpAllowsSync = () => allowsSync; - ret.getCurrentUrl = () => window.location.href; ret.isDoNotTrack = () => { @@ -35,29 +36,20 @@ export default function makeBrowserPlatform() { try { if (window.localStorage) { ret.localStorage = { - get: (key, callback) => { - try { - callback(null, window.localStorage.getItem(key)); - } catch (ex) { - callback(ex); - } - }, - set: (key, value, callback) => { - try { + get: key => + new Promise(resolve => { + resolve(window.localStorage.getItem(key)); + }), + set: (key, value) => + new Promise(resolve => { window.localStorage.setItem(key, value); - callback(null); - } catch (ex) { - callback(ex); - } - }, - clear: (key, callback) => { - try { + resolve(); + }), + clear: key => + new Promise(resolve => { window.localStorage.removeItem(key); - callback(null); - } catch (ex) { - callback(ex); - } - }, + resolve(); + }), }; } } catch (e) { @@ -93,17 +85,3 @@ export default function makeBrowserPlatform() { return ret; } - -// This is temporary logic to disable synchronous XHR in Chrome 73 and above. In all other browsers, -// we will assume it is supported. See https://github.com/launchdarkly/js-client/issues/147 -function isSyncXhrSupported() { - const userAgent = window.navigator && window.navigator.userAgent; - if (userAgent) { - const chromeMatch = userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); - if (chromeMatch) { - const version = parseInt(chromeMatch[2], 10); - return version < 73; - } - } - return true; -} diff --git a/packages/ldclient-js/src/httpRequest.js b/packages/ldclient-js/src/httpRequest.js new file mode 100644 index 00000000..a786fb4e --- /dev/null +++ b/packages/ldclient-js/src/httpRequest.js @@ -0,0 +1,64 @@ +function isSyncXhrSupported() { + // This is temporary logic to disable synchronous XHR in Chrome 73 and above. In all other browsers, + // we will assume it is supported. See https://github.com/launchdarkly/js-client/issues/147 + const userAgent = window.navigator && window.navigator.userAgent; + if (userAgent) { + const chromeMatch = userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); + if (chromeMatch) { + const version = parseInt(chromeMatch[2], 10); + return version < 73; + } + } + return true; +} + +const emptyResult = { promise: Promise.resolve({ status: 200, header: () => null, body: null }) }; + +export default function newHttpRequest(method, url, headers, body, pageIsClosing) { + if (pageIsClosing) { + // When the page is about to close, we have to use synchronous XHR (until we migrate to sendBeacon). + // But not all browsers support this. + if (!isSyncXhrSupported()) { + return emptyResult; + // Note that we return a fake success response, because we don't want the request to be retried in this case. + } + } + + const xhr = new window.XMLHttpRequest(); + xhr.open(method, url, !pageIsClosing); + for (const key in headers || {}) { + if (headers.hasOwnProperty(key)) { + xhr.setRequestHeader(key, headers[key]); + } + } + if (pageIsClosing) { + xhr.send(body); // We specified synchronous mode when we called xhr.open + return emptyResult; // Again, we never want a request to be retried in this case, so we must say it succeeded. + } else { + let cancelled; + const p = new Promise((resolve, reject) => { + xhr.addEventListener('load', () => { + if (cancelled) { + return; + } + resolve({ + status: xhr.status, + header: key => xhr.getResponseHeader(key), + body: xhr.responseText, + }); + }); + xhr.addEventListener('error', () => { + if (cancelled) { + return; + } + reject(new Error()); + }); + xhr.send(body); + }); + const cancel = () => { + cancelled = true; + xhr.abort(); + }; + return { promise: p, cancel: cancel }; + } +} diff --git a/packages/ldclient-js/src/index.js b/packages/ldclient-js/src/index.js index 30e3e797..a0b0c970 100644 --- a/packages/ldclient-js/src/index.js +++ b/packages/ldclient-js/src/index.js @@ -36,7 +36,10 @@ export function initialize(env, user, options = {}) { } else { clientVars.start(); } - window.addEventListener('beforeunload', clientVars.stop); + window.addEventListener('beforeunload', () => { + platform.pageIsClosing = true; + client.close(); + }); return client; } diff --git a/packages/ldclient-react/package-lock.json b/packages/ldclient-react/package-lock.json index 7209ab4d..c96f93ed 100644 --- a/packages/ldclient-react/package-lock.json +++ b/packages/ldclient-react/package-lock.json @@ -1,6 +1,6 @@ { "name": "ldclient-react", - "version": "2.9.6", + "version": "2.9.7", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/rollup.common.config.js b/rollup.common.config.js index 9fea059c..671100ef 100644 --- a/rollup.common.config.js +++ b/rollup.common.config.js @@ -18,6 +18,7 @@ let plugins = [ globals(), builtins(), resolve({ + browser: true, module: true, jsnext: true, main: true, diff --git a/scripts/release-docs.sh b/scripts/release-docs.sh new file mode 100755 index 00000000..3e9ef6cf --- /dev/null +++ b/scripts/release-docs.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# This script generates HTML documentation for the current release and publishes it to the +# "gh-pages" branch of the current repository. The current repository should be the public one, +# and it must already have a "gh-pages" branch. + +# It takes exactly one argument: the new version. +# It should be run from the root of this git repo like this: +# ./scripts/release.sh 4.0.9 + +# The "docs" directory must contain a Makefile that will generate docs into "docs/build/html". +# It will receive the version string in the environment variable $VERSION (in case it is not +# easy for the documentation script to read the version directly from the project). + +set -uxe +echo "Building and releasing documentation." + +export VERSION=$1 + +PROJECT_DIR=$(pwd) +GIT_URL=$(git remote get-url origin) + +TEMP_DIR=$(mktemp -d /tmp/sdk-docs.XXXXXXX) +DOCS_CHECKOUT_DIR=$TEMP_DIR/checkout + +git clone -b gh-pages $GIT_URL $DOCS_CHECKOUT_DIR + +cd $PROJECT_DIR/docs +make + +cd $DOCS_CHECKOUT_DIR + +git rm -r * +touch .nojekyll # this turns off unneeded preprocessing by GH Pages which can break our docs +git add .nojekyll +cp -r $PROJECT_DIR/docs/build/html/* . +git add * +git commit -m "Updating documentation to version $VERSION" +git push origin gh-pages + +cd $PROJECT_DIR +rm -rf $TEMP_DIR diff --git a/scripts/release.sh b/scripts/release.sh index f9e93286..ebd93474 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -41,4 +41,6 @@ for package in ldclient-js-common ldclient-js ldclient-react; do npm publish done +$(PROJECT_DIR)/scripts/release-docs.sh $VERSION + echo "Done with js-client release"