From d7a95a48a6606592cd63b53a6a040f81a5796473 Mon Sep 17 00:00:00 2001 From: Eli Bishop Date: Fri, 11 Jan 2019 16:54:42 -0800 Subject: [PATCH 1/3] prepare 5.7.0 release (#134) --- .babelrc | 16 + README.md | 6 +- caching_store_wrapper.js | 119 +++++- evaluate_flag.js | 7 +- event_processor.js | 2 +- feature_store.js | 15 +- file_data_source.js | 147 +++++++ index.d.ts | 56 +++ index.js | 45 ++- package-lock.json | 590 ++++++++++++++++++++++++++++- package.json | 7 +- redis_feature_store.js | 4 +- test/LDClient-evaluation-test.js | 418 ++++++++++---------- test/LDClient-events-test.js | 206 +++++----- test/LDClient-test.js | 48 +-- test/async_utils.js | 15 + test/caching_store_wrapper-test.js | 444 +++++++++++----------- test/feature_store_test_base.js | 256 +++++-------- test/file_data_source-test.js | 256 +++++++++++++ test/streaming-test.js | 168 ++++---- versioned_data_kind.js | 12 +- 21 files changed, 1926 insertions(+), 911 deletions(-) create mode 100644 .babelrc create mode 100644 file_data_source.js create mode 100644 test/async_utils.js create mode 100644 test/file_data_source-test.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..568faf9 --- /dev/null +++ b/.babelrc @@ -0,0 +1,16 @@ +{ + "env": { + "test": { + "presets": [ + [ + "env", + { + "targets": { + "node": "6" + } + } + ] + ] + } + } +} diff --git a/README.md b/README.md index ab8074e..4b83453 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ Your first feature flag }); }); +Using flag data from a file +--------------------------- + +For testing purposes, the SDK can be made to read feature flag state from a file or files instead of connecting to LaunchDarkly. See `FileDataSource` in the [TypeScript API documentation](https://github.com/launchdarkly/node-client/blob/master/index.d.ts) for more details. Learn more ----------- @@ -84,9 +88,9 @@ About LaunchDarkly * [JavaScript](http://docs.launchdarkly.com/docs/js-sdk-reference "LaunchDarkly JavaScript SDK") * [PHP](http://docs.launchdarkly.com/docs/php-sdk-reference "LaunchDarkly PHP SDK") * [Python](http://docs.launchdarkly.com/docs/python-sdk-reference "LaunchDarkly Python SDK") - * [Python Twisted](http://docs.launchdarkly.com/docs/python-twisted-sdk-reference "LaunchDarkly Python Twisted SDK") * [Go](http://docs.launchdarkly.com/docs/go-sdk-reference "LaunchDarkly Go SDK") * [Node.JS](http://docs.launchdarkly.com/docs/node-sdk-reference "LaunchDarkly Node SDK") + * [Electron](http://docs.launchdarkly.com/docs/electron-sdk-reference "LaunchDarkly Electron SDK") * [.NET](http://docs.launchdarkly.com/docs/dotnet-sdk-reference "LaunchDarkly .Net SDK") * [Ruby](http://docs.launchdarkly.com/docs/ruby-sdk-reference "LaunchDarkly Ruby SDK") * [iOS](http://docs.launchdarkly.com/docs/ios-sdk-reference "LaunchDarkly iOS SDK") diff --git a/caching_store_wrapper.js b/caching_store_wrapper.js index 246eda4..88541e1 100644 --- a/caching_store_wrapper.js +++ b/caching_store_wrapper.js @@ -15,9 +15,50 @@ var initializedKey = "$checkedInit"; /* CachingStoreWrapper provides commonly needed functionality for implementations of an SDK feature store. The underlyingStore must implement a simplified interface for - querying and updating the data store (see redis_feature_store.js for an example) - while CachingStoreWrapper adds optional caching of stored items and of the - initialized state, and ensures that asynchronous operations are serialized correctly. + querying and updating the data store, while CachingStoreWrapper adds optional caching of + stored items and of the initialized state, and ensures that asynchronous operations are + serialized correctly. + + The underlyingStore object must have the following methods: + + - getInternal(kind, key, callback): Queries a single item from the data store. The kind + parameter is an object with a "namespace" property that uniquely identifies the + category of data (features, segments), and the key is the unique key within that + category. It calls the callback with the resulting item as a parameter, or, if no such + item exists, null/undefined. It should not attempt to filter out any items, nor to + cache any items. + + - getAllInternal(kind, callback): Queries all items in a given category from the data + store, calling the callback with an object where each key is the item's key and each + value is the item. It should not attempt to filter out any items, nor to cache any items. + + - upsertInternal(kind, newItem, callback): Adds or updates a single item. If an item with + the same key already exists (in the category specified by "kind"), it should update it + only if the new item's "version" property is greater than the old one. On completion, it + should call the callback with the final state of the item, i.e. if the update succeeded + then it passes the item that was passed in, and if the update failed due to the version + check then it passes the item that is currently in the data store (this ensures that + caching works correctly). Note that deletions are implemented by upserting a placeholder + item with the property "deleted: true". + + - initializedInternal(callback): Tests whether the data store contains a complete data + set, meaning that initInternal() or initOrdereInternal() has been called at least once. + In a shared data store, it should be able to detect this even if the store was + initialized by a different process, i.e. the test should be based on looking at what is + in the data store. The method does not need to worry about caching this value; + CachingStoreWrapper will only call it when necessary. Call callback with true or false. + + - initInternal(allData, callback): Replaces the entire contents of the data store. This + should be done atomically (i.e. within a transaction); if that isn't possible, use + initOrderedInternal() instead. The allData parameter is an object where each key is one + of the "kind" objects, and each value is an object with the keys and values of all + items of that kind. Call callback with no parameters when done. + OR: + - initOrderedInternal(collections, callback): Replaces the entire contents of the data + store. The collections parameter is an array of objects, each of which has "kind" and + "items" properties; "items" is an array of data items. Each array should be processed + in the specified order. The store should delete any obsolete items only after writing + all of the items provided. */ function CachingStoreWrapper(underlyingStore, ttl) { var cache = ttl ? new NodeCache({ stdTTL: ttl }) : null; @@ -28,7 +69,10 @@ function CachingStoreWrapper(underlyingStore, ttl) { this.init = function(allData, cb) { queue.enqueue(function(cb) { - underlyingStore.initInternal(allData, function() { + // The underlying store can either implement initInternal, which receives unordered data, + // or initOrderedInternal, which receives ordered data (for implementations that cannot do + // an atomic update and therefore need to be told what order to do the operations in). + var afterInit = function() { initialized = true; if (cache) { @@ -36,20 +80,25 @@ function CachingStoreWrapper(underlyingStore, ttl) { cache.flushAll(); // populate cache with initial data - for (var kindNamespace in allData) { - if (Object.hasOwnProperty.call(allData, kindNamespace)) { - var kind = dataKind[kindNamespace]; - var items = allData[kindNamespace]; - cache.set(allCacheKey(kind), items); - for (var key in items) { - cache.set(cacheKey(kind, key), items[key]); - } - } - } + Object.keys(allData).forEach(function(kindNamespace) { + var kind = dataKind[kindNamespace]; + var items = allData[kindNamespace]; + cache.set(allCacheKey(kind), items); + Object.keys(items).forEach(function(key) { + cache.set(cacheKey(kind, key), items[key]); + }); + }); } cb(); - }); + }; + + if (underlyingStore.initOrderedInternal) { + var orderedData = sortAllCollections(allData); + underlyingStore.initOrderedInternal(orderedData, afterInit); + } else { + underlyingStore.initInternal(allData, afterInit); + } }, [], cb); }; @@ -141,6 +190,46 @@ function CachingStoreWrapper(underlyingStore, ttl) { cache.del(allCacheKey(dataKind[kindNamespace])); } } + + // This and the next function are used by init() to provide the best ordering of items + // to write the underlying store, if the store supports the initOrderedInternal method. + function sortAllCollections(dataMap) { + var result = []; + Object.keys(dataMap).forEach(function(kindNamespace) { + var kind = dataKind[kindNamespace]; + result.push({ kind: kind, items: sortCollection(kind, dataMap[kindNamespace]) }); + }); + var kindPriority = function(kind) { + return kind.priority === undefined ? kind.namespace.length : kind.priority + }; + result.sort(function(i1, i2) { + return kindPriority(i1.kind) - kindPriority(i2.kind); + }); + return result; + } + + function sortCollection(kind, itemsMap) { + var itemsOut = []; + var remainingItems = new Set(Object.keys(itemsMap)); + var addWithDependenciesFirst = function(key) { + if (remainingItems.has(key)) { + remainingItems.delete(key); + var item = itemsMap[key]; + if (kind.getDependencyKeys) { + kind.getDependencyKeys(item).forEach(function(prereqKey) { + addWithDependenciesFirst(prereqKey); + }); + } + itemsOut.push(item); + } + }; + while (remainingItems.size > 0) { + // pick a random item that hasn't been updated yet + var key = remainingItems.values().next().value; + addWithDependenciesFirst(key); + } + return itemsOut; + } } module.exports = CachingStoreWrapper; diff --git a/evaluate_flag.js b/evaluate_flag.js index 6eb1dcb..cad0ef4 100644 --- a/evaluate_flag.js +++ b/evaluate_flag.js @@ -344,11 +344,10 @@ function bucketUser(user, key, attr, salt) { idHash += "." + user.secondary; } - hashKey = util.format("%s.%s.%s", key, salt, idHash); - hashVal = parseInt(sha1(hashKey).substring(0,15), 16); + var hashKey = util.format("%s.%s.%s", key, salt, idHash); + var hashVal = parseInt(sha1(hashKey).substring(0,15), 16); - result = hashVal / 0xFFFFFFFFFFFFFFF; - return result; + return hashVal / 0xFFFFFFFFFFFFFFF; } function bucketableStringValue(value) { diff --git a/event_processor.js b/event_processor.js index 1a8ec59..cc27216 100644 --- a/event_processor.js +++ b/event_processor.js @@ -44,7 +44,7 @@ function EventProcessor(sdkKey, config, errorReporter) { function makeOutputEvent(event) { switch (event.kind) { case 'feature': - debug = !!event.debug; + var debug = !!event.debug; var out = { kind: debug ? 'debug' : 'feature', creationDate: event.creationDate, diff --git a/feature_store.js b/feature_store.js index ec21e22..083928e 100644 --- a/feature_store.js +++ b/feature_store.js @@ -1,8 +1,17 @@ var dataKind = require('./versioned_data_kind'); -// An in-memory store with an async interface. -// It's async as other implementations (e.g. the RedisFeatureStore) -// may be async, and we want to retain interface compatibility. +// The default in-memory implementation of a feature store, which holds feature flags and +// other related data received from LaunchDarkly. +// +// Other implementations of the same interface can be used by passing them in the featureStore +// property of the client configuration (that's why the interface here is async, even though +// the in-memory store doesn't do anything asynchronous - because other implementations may +// need to be async). The interface is defined by LDFeatureStore in index.d.ts. There is a +// Redis-backed implementation in RedisFeatureStore; for other options, see +// [https://docs.launchdarkly.com/v2.0/docs/using-a-persistent-feature-store]. +// +// Additional implementations should use CachingStoreWrapper if possible. + var noop = function(){}; function InMemoryFeatureStore() { var store = {allData:{}}; diff --git a/file_data_source.js b/file_data_source.js new file mode 100644 index 0000000..2ff3616 --- /dev/null +++ b/file_data_source.js @@ -0,0 +1,147 @@ +var fs = require('fs'), + winston = require('winston'), + yaml = require('yaml'), + dataKind = require('./versioned_data_kind'); + +/* + FileDataSource provides a way to use local files as a source of feature flag state, instead of + connecting to LaunchDarkly. This would typically be used in a test environment. + + See documentation in index.d.ts. +*/ +function FileDataSource(options) { + var paths = (options && options.paths) || []; + var autoUpdate = !!options.autoUpdate; + + return config => { + var featureStore = config.featureStore; + var watchers = []; + var pendingUpdate = false; + var logger = options.logger || config.logger || defaultLogger(); + var inited = false; + + function defaultLogger() { + return new winston.Logger({ + level: 'info', + transports: [ new (winston.transports.Console)() ] + }); + } + + function loadFilePromise(path, allData) { + return new Promise((resolve, reject) => + fs.readFile(path, 'utf8', (err, data) => + err ? reject(err) : resolve(data)) + ).then(data => { + var parsed = parseData(data) || {}; + var addItem = (kind, item) => { + if (!allData[kind.namespace]) { + allData[kind.namespace] = {}; + } + if (allData[kind.namespace][item.key]) { + throw new Error('found duplicate key: "' + item.key + '"'); + } else { + allData[kind.namespace][item.key] = item; + } + } + Object.keys(parsed.flags || {}).forEach(key => { + addItem(dataKind.features, parsed.flags[key]); + }); + Object.keys(parsed.flagValues || {}).forEach(key => { + addItem(dataKind.features, makeFlagWithValue(key, parsed.flagValues[key])); + }); + Object.keys(parsed.segments || {}).forEach(key => { + addItem(dataKind.segments, parsed.segments[key]); + }); + }); + } + + function loadAllPromise() { + pendingUpdate = false; + var allData = {}; + var p = Promise.resolve(); + for (var i = 0; i < paths.length; i++) { + (path => { + p = p.then(() => loadFilePromise(path, allData)) + .catch(e => { + throw new Error('Unable to load flags: ' + e + ' [' + path + ']'); + }); + })(paths[i]); + } + return p.then(() => initStorePromise(allData)); + } + + function initStorePromise(data) { + return new Promise(resolve => featureStore.init(data, () => { + inited = true; + resolve(); + })); + } + + function parseData(data) { + // Every valid JSON document is also a valid YAML document (for parsers that comply + // with the spec, which this one does) so we can parse both with the same parser. + return yaml.parse(data); + } + + function makeFlagWithValue(key, value) { + return { + key: key, + on: true, + fallthrough: { variation: 0 }, + variations: [ value ] + }; + } + + function startWatching() { + var reload = () => { + loadAllPromise().then(() => { + logger && logger.warn('Reloaded flags from file data'); + }).catch(() => {}); + }; + paths.forEach(path => { + var watcher = fs.watch(path, { persistent: false }, (event, filename) => { + if (!pendingUpdate) { // coalesce updates to avoid reloading repeatedly + pendingUpdate = true; + setTimeout(reload, 0); + } + }); + watchers.push(watcher); + }); + } + + function stopWatching() { + watchers.forEach(w => w.close()); + watchers = []; + } + + var fds = {}; + + fds.start = fn => { + var cb = fn || (() => {}); + + if (autoUpdate) { + startWatching(); + } + + loadAllPromise().then(() => cb(), err => cb(err)); + }; + + fds.stop = () => { + if (autoUpdate) { + stopWatching(); + } + }; + + fds.initialized = () => { + return inited; + }; + + fds.close = () => { + fds.stop(); + }; + + return fds; + } +} + +module.exports = FileDataSource; diff --git a/index.d.ts b/index.d.ts index 2b526ed..1fb101d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -754,6 +754,62 @@ declare module 'ldclient-node' { */ flush: (callback?: (err: any, res: boolean) => void) => Promise; } + + /** + * Configuration for FileDataSource. + */ + export interface FileDataSourceOptions { + /** + * The path(s) of the file(s) that FileDataSource will read. + */ + paths: Array; + + /** + * True if FileDataSource should reload flags whenever one of the data files is modified. + * This feature uses Node's fs.watch() API, so it is subject to + * the limitations described here: https://nodejs.org/docs/latest/api/fs.html#fs_fs_watch_filename_options_listener + */ + autoUpdate?: boolean; + + /** + * Configures a logger for warnings and errors. This can be a custom logger or an instance of + * winston.Logger. By default, it uses the same logger as the rest of the SDK. + */ + logger?: LDLogger | object; + } + + /** + * Creates an object that allows you to use local files as a source of feature flag state, + * instead of connecting to LaunchDarkly. This would typically be used in a test environment. + *

+ * To use this component, call FileDataSource(options) and store the result in the "updateProcessor" + * property of your LaunchDarkly client configuration: + *

+   *     var dataSource = LaunchDarkly.FileDataSource({ paths: [ myFilePath ] });
+   *     var config = { updateProcessor: dataSource };
+   * 
+ *

+ * Flag data files can be either JSON or YAML. They contain an object with three possible + * properties: + *

    + *
  • "flags": Full feature flag definitions. + *
  • "flagValues": Simplified feature flags, just a map of flag keys to values. + *
  • "segments": User segment definitions. + *
+ *

+ * The format of the data in "flags" and "segments" is defined by the LaunchDarkly application + * and is subject to change. You can query existing flags and segments from LaunchDarkly in JSON + * format by querying https://app.launchdarkly.com/sdk/latest-all and passing your SDK key in + * the Authorization header. + *

+ * For more details, see the LaunchDarkly reference guide: + * https://docs.launchdarkly.com/v2.0/docs/reading-flags-from-a-file + * + * @param options configuration for the data source; you should at least set the "paths" property + */ + export function FileDataSource( + options: FileDataSourceOptions + ): object; } declare module 'ldclient-node/streaming' { diff --git a/index.js b/index.js index 0a2980e..73c4239 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ var FeatureStoreEventWrapper = require('./feature_store_event_wrapper'); var RedisFeatureStore = require('./redis_feature_store'); +var FileDataSource = require('./file_data_source'); var Requestor = require('./requestor'); var EventEmitter = require('events').EventEmitter; var EventProcessor = require('./event_processor'); @@ -55,13 +56,12 @@ var newClient = function(sdkKey, config) { var client = new EventEmitter(), initComplete = false, failure, - queue = [], requestor, updateProcessor, eventProcessor; config = configuration.validate(config); - + // Initialize global tunnel if proxy options are set if (config.proxyHost && config.proxyPort ) { config.proxyAgent = createProxyAgent(config); @@ -85,22 +85,34 @@ var newClient = function(sdkKey, config) { throw new Error("You must configure the client with an SDK key"); } + var createDefaultUpdateProcessor = function(config) { + if (config.useLdd || config.offline) { + return NullUpdateProcessor(); + } else { + requestor = Requestor(sdkKey, config); + + if (config.stream) { + config.logger.info("Initializing stream processor to receive feature flag updates"); + return StreamingProcessor(sdkKey, config, requestor); + } else { + config.logger.info("Initializing polling processor to receive feature flag updates"); + config.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); + return PollingProcessor(config, requestor); + } + } + } + var updateProcessorFactory = createDefaultUpdateProcessor; if (config.updateProcessor) { - updateProcessor = config.updateProcessor; - } else if (config.useLdd || config.offline) { - updateProcessor = NullUpdateProcessor(); - } else { - requestor = Requestor(sdkKey, config); - - if (config.stream) { - config.logger.info("Initializing stream processor to receive feature flag updates"); - updateProcessor = StreamingProcessor(sdkKey, config, requestor); + if (typeof config.updateProcessor === 'function') { + updateProcessorFactory = config.updateProcessor; } else { - config.logger.info("Initializing polling processor to receive feature flag updates"); - config.logger.warn("You should only disable the streaming API if instructed to do so by LaunchDarkly support"); - updateProcessor = PollingProcessor(config, requestor); + updateProcessor = config.updateProcessor; } } + if (!updateProcessor) { + updateProcessor = updateProcessorFactory(config); + } + updateProcessor.start(function(err) { if (err) { var error; @@ -196,7 +208,7 @@ var newClient = function(sdkKey, config) { } else if (!key) { - err = new errors.LDClientError('No feature flag key specified. Returning default value.'); + var err = new errors.LDClientError('No feature flag key specified. Returning default value.'); maybeReportError(err); return resolve(errorResult('FLAG_NOT_FOUND', defaultVal)); } @@ -208,7 +220,7 @@ var newClient = function(sdkKey, config) { config.featureStore.get(dataKind.features, key, function(flag) { if (!user) { - variationErr = new errors.LDClientError('No user specified. Returning default value.'); + var variationErr = new errors.LDClientError('No user specified. Returning default value.'); maybeReportError(variationErr); var result = errorResult('USER_NOT_SPECIFIED', defaultVal); sendFlagEvent(key, flag, user, result, defaultVal, includeReasonsInEvents); @@ -378,6 +390,7 @@ var newClient = function(sdkKey, config) { module.exports = { init: newClient, RedisFeatureStore: RedisFeatureStore, + FileDataSource: FileDataSource, errors: errors }; diff --git a/package-lock.json b/package-lock.json index a42cde1..3b99cfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ldclient-node", - "version": "5.6.0", + "version": "5.6.2", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -547,9 +547,9 @@ } }, "babel-core": { - "version": "6.26.3", - "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", - "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz", + "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=", "dev": true, "requires": { "babel-code-frame": "^6.26.0", @@ -562,15 +562,15 @@ "babel-traverse": "^6.26.0", "babel-types": "^6.26.0", "babylon": "^6.18.0", - "convert-source-map": "^1.5.1", - "debug": "^2.6.9", + "convert-source-map": "^1.5.0", + "debug": "^2.6.8", "json5": "^0.5.1", "lodash": "^4.17.4", "minimatch": "^3.0.4", "path-is-absolute": "^1.0.1", - "private": "^0.1.8", + "private": "^0.1.7", "slash": "^1.0.0", - "source-map": "^0.5.7" + "source-map": "^0.5.6" } }, "babel-generator": { @@ -589,6 +589,133 @@ "trim-right": "^1.0.1" } }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "dev": true, + "requires": { + "babel-helper-explode-assignable-expression": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "dev": true, + "requires": { + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "dev": true, + "requires": { + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, "babel-helpers": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", @@ -600,13 +727,13 @@ } }, "babel-jest": { - "version": "22.4.4", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-22.4.4.tgz", - "integrity": "sha512-A9NB6/lZhYyypR9ATryOSDcqBaqNdzq4U+CN+/wcMsLcmKkPxQEoTKLajGfd3IkxNyVBT8NewUK2nWyGbSzHEQ==", + "version": "22.4.3", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-22.4.3.tgz", + "integrity": "sha512-BgSjmtl3mW3i+VeVHEr9d2zFSAT66G++pJcHQiUjd00pkW+voYXFctIm/indcqOWWXw5a1nUpR1XWszD9fJ1qg==", "dev": true, "requires": { "babel-plugin-istanbul": "^4.1.5", - "babel-preset-jest": "^22.4.4" + "babel-preset-jest": "^22.4.3" } }, "babel-messages": { @@ -618,6 +745,15 @@ "babel-runtime": "^6.22.0" } }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, "babel-plugin-istanbul": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-4.1.6.tgz", @@ -636,12 +772,343 @@ "integrity": "sha512-DUvGfYaAIlkdnygVIEl0O4Av69NtuQWcrjMOv6DODPuhuGLDnbsARz3AwiiI/EkIMMlxQDUcrZ9yoyJvTNjcVQ==", "dev": true }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=", + "dev": true + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", + "dev": true + }, "babel-plugin-syntax-object-rest-spread": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/babel-plugin-syntax-object-rest-spread/-/babel-plugin-syntax-object-rest-spread-6.13.0.tgz", "integrity": "sha1-/WU28rzhODb/o6VFjEkDpZe7O/U=", "dev": true }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=", + "dev": true + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "dev": true, + "requires": { + "babel-helper-remap-async-to-generator": "^6.24.1", + "babel-plugin-syntax-async-functions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "dev": true, + "requires": { + "babel-helper-define-map": "^6.24.1", + "babel-helper-function-name": "^6.24.1", + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-helper-replace-supers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz", + "integrity": "sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==", + "dev": true, + "requires": { + "babel-plugin-transform-strict-mode": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-types": "^6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-amd": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "dev": true, + "requires": { + "babel-helper-replace-supers": "^6.24.1", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "dev": true, + "requires": { + "babel-helper-call-delegate": "^6.24.1", + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "regexpu-core": "^2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "dev": true, + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1", + "babel-plugin-syntax-exponentiation-operator": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "dev": true, + "requires": { + "regenerator-transform": "^0.10.0" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-preset-env": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.6.1.tgz", + "integrity": "sha512-W6VIyA6Ch9ePMI7VptNn2wBM6dbG0eSz25HEiL40nQXCsXGTGZSTZu1Iap+cj3Q0S5a7T9+529l/5Bkvd+afNA==", + "dev": true, + "requires": { + "babel-plugin-check-es2015-constants": "^6.22.0", + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoping": "^6.23.0", + "babel-plugin-transform-es2015-classes": "^6.23.0", + "babel-plugin-transform-es2015-computed-properties": "^6.22.0", + "babel-plugin-transform-es2015-destructuring": "^6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0", + "babel-plugin-transform-es2015-for-of": "^6.23.0", + "babel-plugin-transform-es2015-function-name": "^6.22.0", + "babel-plugin-transform-es2015-literals": "^6.22.0", + "babel-plugin-transform-es2015-modules-amd": "^6.22.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-umd": "^6.23.0", + "babel-plugin-transform-es2015-object-super": "^6.22.0", + "babel-plugin-transform-es2015-parameters": "^6.23.0", + "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0", + "babel-plugin-transform-es2015-spread": "^6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "^6.22.0", + "babel-plugin-transform-es2015-template-literals": "^6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "^6.22.0", + "babel-plugin-transform-exponentiation-operator": "^6.22.0", + "babel-plugin-transform-regenerator": "^6.22.0", + "browserslist": "^2.1.2", + "invariant": "^2.2.2", + "semver": "^5.3.0" + } + }, "babel-preset-jest": { "version": "22.4.4", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-22.4.4.tgz", @@ -854,6 +1321,16 @@ "resolve": "1.1.7" } }, + "browserslist": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.11.3.tgz", + "integrity": "sha512-yWu5cXT7Av6mVwzWc8lMsJMHWn4xyjSuGYi4IozbVTLUOEYPSagUB8kiMDUHA1fS3zjr8nkxkn9jdvug4BBRmA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000792", + "electron-to-chromium": "^1.3.30" + } + }, "bser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/bser/-/bser-2.0.0.tgz", @@ -913,6 +1390,12 @@ "dev": true, "optional": true }, + "caniuse-lite": { + "version": "1.0.30000927", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000927.tgz", + "integrity": "sha512-ogq4NbUWf1uG/j66k0AmiO3GjqJAlQyF8n4w8a954cbCyFKmYGvRtgz6qkq2fWuduTXHibX7GyYL5Pg58Aks2g==", + "dev": true + }, "capture-exit": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-1.2.0.tgz", @@ -1371,6 +1854,12 @@ "safer-buffer": "^2.1.0" } }, + "electron-to-chromium": { + "version": "1.3.100", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.100.tgz", + "integrity": "sha512-cEUzis2g/RatrVf8x26L8lK5VEls1AGnLHk6msluBUg/NTB4wcXzExTsGscFq+Vs4WBBU2zbLLySvD4C0C3hwg==", + "dev": true + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3148,6 +3637,18 @@ "strip-bom": "3.0.0", "write-file-atomic": "^2.1.0", "yargs": "^10.0.3" + }, + "dependencies": { + "babel-jest": { + "version": "22.4.4", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-22.4.4.tgz", + "integrity": "sha512-A9NB6/lZhYyypR9ATryOSDcqBaqNdzq4U+CN+/wcMsLcmKkPxQEoTKLajGfd3IkxNyVBT8NewUK2nWyGbSzHEQ==", + "dev": true, + "requires": { + "babel-plugin-istanbul": "^4.1.5", + "babel-preset-jest": "^22.4.4" + } + } } }, "jest-snapshot": { @@ -4587,12 +5088,29 @@ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz", "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs=" }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, "regenerator-runtime": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", "integrity": "sha1-vgWtf5v30i4Fb5cmzuUBf78Z4uk=", "dev": true }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "dev": true, + "requires": { + "babel-runtime": "^6.18.0", + "babel-types": "^6.19.0", + "private": "^0.1.6" + } + }, "regex-cache": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz", @@ -4612,6 +5130,40 @@ "safe-regex": "^1.1.0" } }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "dev": true, + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, "remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -5759,6 +6311,15 @@ "integrity": "sha1-iQN8vJLFarGJJua6TLsgDhVnKmo=", "dev": true }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", @@ -6243,6 +6804,11 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=" }, + "yaml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.0.1.tgz", + "integrity": "sha512-ysU56qumPH0tEML2hiFVAo+rCnG/0+oO2Ye3fN4c40GBN7kX1fYhDqSoX7OohimcI/Xkkr1DdaUlg5+afinRqA==" + }, "yargs": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", diff --git a/package.json b/package.json index 30361a7..4625be9 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,20 @@ "request-etag": "^2.0.3", "semver": "5.5.0", "tunnel": "0.0.6", - "winston": "2.4.1" + "winston": "2.4.1", + "yaml": "1.0.1" }, "engines": { "node": ">= 0.8.x" }, "devDependencies": { + "babel-core": "6.26.0", + "babel-jest": "22.4.3", + "babel-preset-env": "1.6.1", "jest": "22.4.3", "jest-junit": "3.6.0", "nock": "9.2.3", + "tmp": "0.0.33", "typescript": "3.0.1" }, "jest": { diff --git a/redis_feature_store.js b/redis_feature_store.js index 009aae1..da1aecb 100644 --- a/redis_feature_store.js +++ b/redis_feature_store.js @@ -27,8 +27,8 @@ function redisFeatureStoreInternal(redisOpts, prefix, logger) { }) ); - connected = false; - initialConnect = true; + var connected = false; + var initialConnect = true; client.on('error', function(err) { // Note that we *must* have an error listener or else any connection error will trigger an // uncaught exception. diff --git a/test/LDClient-evaluation-test.js b/test/LDClient-evaluation-test.js index 1794e4e..4ff1dfe 100644 --- a/test/LDClient-evaluation-test.js +++ b/test/LDClient-evaluation-test.js @@ -17,7 +17,7 @@ describe('LDClient', () => { } describe('variation()', () => { - it('evaluates an existing flag', done => { + it('evaluates an existing flag', async () => { var flag = { key: 'flagkey', version: 1, @@ -28,27 +28,19 @@ describe('LDClient', () => { trackEvents: true }; var client = stubs.createClient({}, { flagkey: flag }); - client.on('ready', () => { - client.variation(flag.key, defaultUser, 'c', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('b'); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variation(flag.key, defaultUser, 'c'); + expect(result).toEqual('b'); }); - it('returns default for unknown flag', done => { + it('returns default for unknown flag', async () => { var client = stubs.createClient({}, {}); - client.on('ready', () => { - client.variation('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variation('flagkey', defaultUser, 'default'); + expect(result).toEqual('default'); }); - it('returns default if client is offline', done => { + it('returns default if client is offline', async () => { var flag = { key: 'flagkey', version: 1, @@ -57,16 +49,14 @@ describe('LDClient', () => { variations: ['value'] }; var logger = stubs.stubLogger(); - client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); - client.variation('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('default'); - expect(logger.info).toHaveBeenCalled(); - done(); - }); + var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + await client.waitForInitialization(); + var result = await client.variation('flagkey', defaultUser, 'default'); + expect(result).toEqual('default'); + expect(logger.info).toHaveBeenCalled(); }); - it('returns default if client and store are not initialized', done => { + it('returns default if client and store are not initialized', async () => { var flag = { key: 'flagkey', version: 1, @@ -75,14 +65,11 @@ describe('LDClient', () => { variations: ['value'] }; var client = createClientWithFlagsInUninitializedStore({ flagkey: flag }); - client.variation('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); + var result = await client.variation('flagkey', defaultUser, 'default'); + expect(result).toEqual('default'); }); - it('returns value from store if store is initialized but client is not', done => { + it('returns value from store if store is initialized but client is not', async () => { var flag = { key: 'flagkey', version: 1, @@ -93,40 +80,29 @@ describe('LDClient', () => { var logger = stubs.stubLogger(); var updateProcessor = stubs.stubUpdateProcessor(); updateProcessor.shouldInitialize = false; - client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); - client.variation('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('value'); - expect(logger.warn).toHaveBeenCalled(); - done(); - }); + var client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); + var result = await client.variation('flagkey', defaultUser, 'default'); + expect(result).toEqual('value'); + expect(logger.warn).toHaveBeenCalled(); }); - it('returns default if flag key is not specified', done => { + it('returns default if flag key is not specified', async () => { var client = stubs.createClient({}, {}); - client.on('ready', () => { - client.variation(null, defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variation(null, defaultUser, 'default'); + expect(result).toEqual('default'); }); - it('returns default for flag that evaluates to null', done => { + it('returns default for flag that evaluates to null', async () => { var flag = { key: 'flagkey', on: false, offVariation: null }; var client = stubs.createClient({}, { flagkey: flag }); - client.on('ready', () => { - client.variation(flag.key, defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toEqual('default'); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variation(flag.key, defaultUser, 'default'); + expect(result).toEqual('default'); }); it('allows deprecated method toggle()', done => { @@ -147,10 +123,21 @@ describe('LDClient', () => { }); }); }); + + it('can use a callback instead of a Promise', done => { + var client = stubs.createClient({}, {}); + client.on('ready', () => { + client.variation('flagkey', defaultUser, 'default', (err, result) => { + expect(err).toBeNull(); + expect(result).toEqual('default'); + done(); + }); + }); + }); }); describe('variationDetail()', () => { - it('evaluates an existing flag', done => { + it('evaluates an existing flag', async () => { var flag = { key: 'flagkey', version: 1, @@ -161,28 +148,20 @@ describe('LDClient', () => { trackEvents: true }; var client = stubs.createClient({}, { flagkey: flag }); - client.on('ready', () => { - client.variationDetail(flag.key, defaultUser, 'c', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'b', variationIndex: 1, reason: { kind: 'FALLTHROUGH' } }); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variationDetail(flag.key, defaultUser, 'c'); + expect(result).toMatchObject({ value: 'b', variationIndex: 1, reason: { kind: 'FALLTHROUGH' } }); }); - it('returns default for unknown flag', done => { + it('returns default for unknown flag', async () => { var client = stubs.createClient({}, { }); - client.on('ready', () => { - client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variationDetail('flagkey', defaultUser, 'default'); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); }); - it('returns default if client is offline', done => { + it('returns default if client is offline', async () => { var flag = { key: 'flagkey', version: 1, @@ -191,17 +170,15 @@ describe('LDClient', () => { variations: ['value'] }; var logger = stubs.stubLogger(); - client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); - client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }}); - expect(logger.info).toHaveBeenCalled(); - done(); - }); + var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + await client.waitForInitialization(); + var result = await client.variationDetail('flagkey', defaultUser, 'default'); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' }}); + expect(logger.info).toHaveBeenCalled(); }); - it('returns default if client and store are not initialized', done => { + it('returns default if client and store are not initialized', async () => { var flag = { key: 'flagkey', version: 1, @@ -209,16 +186,13 @@ describe('LDClient', () => { offVariation: 0, variations: ['value'] }; - client = createClientWithFlagsInUninitializedStore({ flagkey: flag }); - client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' } }); - done(); - }); + var client = createClientWithFlagsInUninitializedStore({ flagkey: flag }); + var result = await client.variationDetail('flagkey', defaultUser, 'default'); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'CLIENT_NOT_READY' } }); }); - it('returns value from store if store is initialized but client is not', done => { + it('returns value from store if store is initialized but client is not', async () => { var flag = { key: 'flagkey', version: 1, @@ -229,38 +203,39 @@ describe('LDClient', () => { var logger = stubs.stubLogger(); var updateProcessor = stubs.stubUpdateProcessor(); updateProcessor.shouldInitialize = false; - client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); - client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'value', variationIndex: 0, reason: { kind: 'OFF' }}) - expect(logger.warn).toHaveBeenCalled(); - done(); - }); + var client = stubs.createClient({ updateProcessor: updateProcessor, logger: logger }, { flagkey: flag }); + var result = await client.variationDetail('flagkey', defaultUser, 'default'); + expect(result).toMatchObject({ value: 'value', variationIndex: 0, reason: { kind: 'OFF' }}) + expect(logger.warn).toHaveBeenCalled(); }); - it('returns default if flag key is not specified', done => { + it('returns default if flag key is not specified', async () => { var client = stubs.createClient({}, { }); - client.on('ready', () => { - client.variationDetail(null, defaultUser, 'default', (err, result) => { - expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.variationDetail(null, defaultUser, 'default'); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); }); - it('returns default for flag that evaluates to null', done => { + it('returns default for flag that evaluates to null', async () => { var flag = { key: 'flagkey', on: false, offVariation: null }; var client = stubs.createClient({}, { flagkey: flag }); + await client.waitForInitialization(); + var result = await client.variationDetail(flag.key, defaultUser, 'default'); + expect(result).toMatchObject({ value: 'default', variationIndex: null, reason: { kind: 'OFF' } }); + }); + + it('can use a callback instead of a Promise', done => { + var client = stubs.createClient({}, {}); client.on('ready', () => { - client.variationDetail(flag.key, defaultUser, 'default', (err, result) => { + client.variationDetail('flagkey', defaultUser, 'default', (err, result) => { expect(err).toBeNull(); - expect(result).toMatchObject({ value: 'default', variationIndex: null, reason: { kind: 'OFF' } }); + expect(result).toMatchObject({ value: 'default', variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' } }); done(); }); }); @@ -268,7 +243,7 @@ describe('LDClient', () => { }); describe('allFlags()', () => { - it('evaluates flags', done => { + it('evaluates flags', async () => { var flag = { key: 'feature', version: 1, @@ -277,17 +252,13 @@ describe('LDClient', () => { }; var logger = stubs.stubLogger(); var client = stubs.createClient({ logger: logger }, { feature: flag }); - client.on('ready', () => { - client.allFlags(defaultUser, (err, results) => { - expect(err).toBeNull(); - expect(results).toEqual({feature: 'b'}); - expect(logger.warn).toHaveBeenCalledTimes(1); // deprecation warning - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.allFlags(defaultUser); + expect(result).toEqual({feature: 'b'}); + expect(logger.warn).toHaveBeenCalledTimes(1); // deprecation warning }); - it('returns empty map in offline mode and logs a message', done => { + it('returns empty map in offline mode and logs a message', async () => { var flag = { key: 'flagkey', on: false, @@ -295,13 +266,10 @@ describe('LDClient', () => { }; var logger = stubs.stubLogger(); var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); - client.on('ready', () => { - client.allFlags(defaultUser, (err, result) => { - expect(result).toEqual({}); - expect(logger.info).toHaveBeenCalledTimes(1); - done(); - }); - }); + await client.waitForInitialization(); + var result = await client.allFlags(defaultUser); + expect(result).toEqual({}); + expect(logger.info).toHaveBeenCalledTimes(1); }); it('allows deprecated method all_flags', done => { @@ -316,7 +284,7 @@ describe('LDClient', () => { }); }); - it('does not overflow the call stack when evaluating a huge number of flags', done => { + it('does not overflow the call stack when evaluating a huge number of flags', async () => { var flagCount = 5000; var flags = {}; for (var i = 0; i < flagCount; i++) { @@ -329,10 +297,16 @@ describe('LDClient', () => { flags[key] = flag; } var client = stubs.createClient({}, flags); + await client.waitForInitialization(); + var result = await client.allFlags(defaultUser); + expect(Object.keys(result).length).toEqual(flagCount); + }); + + it('can use a callback instead of a Promise', done => { + var client = stubs.createClient({ offline: true }, { }); client.on('ready', () => { client.allFlags(defaultUser, (err, result) => { - expect(err).toEqual(null); - expect(Object.keys(result).length).toEqual(flagCount); + expect(result).toEqual({}); done(); }); }); @@ -340,7 +314,7 @@ describe('LDClient', () => { }); describe('allFlagsState()', () => { - it('captures flag state', done => { + it('captures flag state', async () => { var flag = { key: 'feature', version: 100, @@ -350,30 +324,26 @@ describe('LDClient', () => { debugEventsUntilDate: 1000 }; var client = stubs.createClient({}, { feature: flag }); - client.on('ready', () => { - client.allFlagsState(defaultUser, {}, (err, state) => { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({feature: 'b'}); - expect(state.getFlagValue('feature')).toEqual('b'); - expect(state.toJSON()).toEqual({ - feature: 'b', - $flagsState: { - feature: { - version: 100, - variation: 1, - trackEvents: true, - debugEventsUntilDate: 1000 - } - }, - $valid: true - }); - done(); - }); + await client.waitForInitialization(); + var state = await client.allFlagsState(defaultUser); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({feature: 'b'}); + expect(state.getFlagValue('feature')).toEqual('b'); + expect(state.toJSON()).toEqual({ + feature: 'b', + $flagsState: { + feature: { + version: 100, + variation: 1, + trackEvents: true, + debugEventsUntilDate: 1000 + } + }, + $valid: true }); }); - it('can filter for only client-side flags', done => { + it('can filter for only client-side flags', async () => { var flag1 = { key: 'server-side-1', on: false, offVariation: 0, variations: ['a'], clientSide: false }; var flag2 = { key: 'server-side-2', on: false, offVariation: 0, variations: ['b'], clientSide: false }; var flag3 = { key: 'client-side-1', on: false, offVariation: 0, variations: ['value1'], clientSide: true }; @@ -381,30 +351,13 @@ describe('LDClient', () => { var client = stubs.createClient({}, { 'server-side-1': flag1, 'server-side-2': flag2, 'client-side-1': flag3, 'client-side-2': flag4 }); - client.on('ready', () => { - client.allFlagsState(defaultUser, { clientSideOnly: true }, (err, state) => { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); - done(); - }); - }); + await client.waitForInitialization(); + var state = await client.allFlagsState(defaultUser, { clientSideOnly: true }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({ 'client-side-1': 'value1', 'client-side-2': 'value2' }); }); - it('can omit options parameter', done => { - var flag = { key: 'key', on: false, offVariation: 0, variations: ['value'] }; - var client = stubs.createClient({}, { 'key': flag }); - client.on('ready', () => { - client.allFlagsState(defaultUser, (err, state) => { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({ 'key': 'value' }); - done(); - }); - }); - }); - - it('can include reasons', done => { + it('can include reasons', async () => { var flag = { key: 'feature', version: 100, @@ -414,31 +367,27 @@ describe('LDClient', () => { debugEventsUntilDate: 1000 }; var client = stubs.createClient({}, { feature: flag }); - client.on('ready', () => { - client.allFlagsState(defaultUser, { withReasons: true }, (err, state) => { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({feature: 'b'}); - expect(state.getFlagValue('feature')).toEqual('b'); - expect(state.toJSON()).toEqual({ - feature: 'b', - $flagsState: { - feature: { - version: 100, - variation: 1, - reason: { kind: 'OFF' }, - trackEvents: true, - debugEventsUntilDate: 1000 - } - }, - $valid: true - }); - done(); - }); + await client.waitForInitialization(); + var state = await client.allFlagsState(defaultUser, { withReasons: true }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({feature: 'b'}); + expect(state.getFlagValue('feature')).toEqual('b'); + expect(state.toJSON()).toEqual({ + feature: 'b', + $flagsState: { + feature: { + version: 100, + variation: 1, + reason: { kind: 'OFF' }, + trackEvents: true, + debugEventsUntilDate: 1000 + } + }, + $valid: true }); }); - it('can omit details for untracked flags', done => { + it('can omit details for untracked flags', async () => { var flag1 = { key: 'flag1', version: 100, @@ -461,41 +410,37 @@ describe('LDClient', () => { }; var client = stubs.createClient({}, { flag1: flag1, flag2: flag2, flag3: flag3 }); var user = { key: 'user' }; - client.on('ready', function() { - client.allFlagsState(user, { withReasons: true, detailsOnlyForTrackedFlags: true }, function(err, state) { - expect(err).toBeNull(); - expect(state.valid).toEqual(true); - expect(state.allValues()).toEqual({flag1: 'value1', flag2: 'value2', flag3: 'value3'}); - expect(state.getFlagValue('flag1')).toEqual('value1'); - expect(state.toJSON()).toEqual({ - flag1: 'value1', - flag2: 'value2', - flag3: 'value3', - $flagsState: { - flag1: { - variation: 0 - }, - flag2: { - version: 200, - variation: 0, - reason: { kind: 'OFF' }, - trackEvents: true - }, - flag3: { - version: 300, - variation: 0, - reason: { kind: 'OFF' }, - debugEventsUntilDate: 1000 - } - }, - $valid: true - }); - done(); - }); + await client.waitForInitialization(); + var state = await client.allFlagsState(defaultUser, { withReasons: true, detailsOnlyForTrackedFlags: true }); + expect(state.valid).toEqual(true); + expect(state.allValues()).toEqual({flag1: 'value1', flag2: 'value2', flag3: 'value3'}); + expect(state.getFlagValue('flag1')).toEqual('value1'); + expect(state.toJSON()).toEqual({ + flag1: 'value1', + flag2: 'value2', + flag3: 'value3', + $flagsState: { + flag1: { + variation: 0 + }, + flag2: { + version: 200, + variation: 0, + reason: { kind: 'OFF' }, + trackEvents: true + }, + flag3: { + version: 300, + variation: 0, + reason: { kind: 'OFF' }, + debugEventsUntilDate: 1000 + } + }, + $valid: true }); }); - it('returns empty state in offline mode and logs a message', done => { + it('returns empty state in offline mode and logs a message', async () => { var flag = { key: 'flagkey', on: false, @@ -503,11 +448,28 @@ describe('LDClient', () => { }; var logger = stubs.stubLogger(); var client = stubs.createClient({ offline: true, logger: logger }, { flagkey: flag }); + await client.waitForInitialization(); + var state = await client.allFlagsState(defaultUser); + expect(state.valid).toEqual(false); + expect(state.allValues()).toEqual({}); + expect(logger.info).toHaveBeenCalledTimes(1); + }); + + it('can use a callback instead of a Promise', done => { + var client = stubs.createClient({ offline: true }, { }); client.on('ready', () => { client.allFlagsState(defaultUser, {}, (err, state) => { expect(state.valid).toEqual(false); - expect(state.allValues()).toEqual({}); - expect(logger.info).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + + it('can omit options parameter with callback', done => { + var client = stubs.createClient({ offline: true }, { }); + client.on('ready', () => { + client.allFlagsState(defaultUser, (err, state) => { + expect(state.valid).toEqual(false); done(); }); }); diff --git a/test/LDClient-events-test.js b/test/LDClient-events-test.js index 2df637a..94f7628 100644 --- a/test/LDClient-events-test.js +++ b/test/LDClient-events-test.js @@ -10,7 +10,7 @@ describe('LDClient - analytics events', () => { }); describe('feature event', () => { - it('generates event for existing feature', done => { + it('generates event for existing feature', async () => { var flag = { key: 'flagkey', version: 1, @@ -21,26 +21,24 @@ describe('LDClient - analytics events', () => { trackEvents: true }; var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); - client.on('ready', () => { - client.variation(flag.key, defaultUser, 'c', (err, result) => { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: defaultUser, - variation: 1, - value: 'b', - default: 'c', - trackEvents: true - }); - done(); - }); + await client.waitForInitialization(); + await client.variation(flag.key, defaultUser, 'c'); + + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: defaultUser, + variation: 1, + value: 'b', + default: 'c', + trackEvents: true }); }); - it('generates event for existing feature with reason', done => { + it('generates event for existing feature with reason', async () => { var flag = { key: 'flagkey', version: 1, @@ -51,48 +49,44 @@ describe('LDClient - analytics events', () => { trackEvents: true }; var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); - client.on('ready', () => { - client.variationDetail(flag.key, defaultUser, 'c', (err, result) => { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: defaultUser, - variation: 1, - value: 'b', - default: 'c', - reason: { kind: 'FALLTHROUGH' }, - trackEvents: true - }); - done(); - }); + await client.waitForInitialization(); + await client.variationDetail(flag.key, defaultUser, 'c'); + + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: defaultUser, + variation: 1, + value: 'b', + default: 'c', + reason: { kind: 'FALLTHROUGH' }, + trackEvents: true }); }); - it('generates event for unknown feature', done => { + it('generates event for unknown feature', async () => { var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); - client.on('ready', () => { - client.variation('flagkey', defaultUser, 'c', (err, result) => { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: null, - user: defaultUser, - variation: null, - value: 'c', - default: 'c', - trackEvents: null - }); - done(); - }); + await client.waitForInitialization(); + await client.variation('flagkey', defaultUser, 'c'); + + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: null, + user: defaultUser, + variation: null, + value: 'c', + default: 'c', + trackEvents: null }); }); - it('generates event for existing feature when user key is missing', done => { + it('generates event for existing feature when user key is missing', async () => { var flag = { key: 'flagkey', version: 1, @@ -104,26 +98,24 @@ describe('LDClient - analytics events', () => { }; var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); var badUser = { name: 'Bob' }; - client.on('ready', () => { - client.variation(flag.key, badUser, 'c', (err, result) => { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: badUser, - variation: null, - value: 'c', - default: 'c', - trackEvents: true - }); - done(); - }); + await client.waitForInitialization(); + await client.variation(flag.key, badUser, 'c'); + + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'feature', + key: 'flagkey', + version: 1, + user: badUser, + variation: null, + value: 'c', + default: 'c', + trackEvents: true }); }); - it('generates event for existing feature when user is null', done => { + it('generates event for existing feature when user is null', async () => { var flag = { key: 'flagkey', version: 1, @@ -134,55 +126,51 @@ describe('LDClient - analytics events', () => { trackEvents: true }; var client = stubs.createClient({ eventProcessor: eventProcessor }, { flagkey: flag }); - client.on('ready', () => { - client.variation(flag.key, null, 'c', (err, result) => { - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'feature', - key: 'flagkey', - version: 1, - user: null, - variation: null, - value: 'c', - default: 'c', - trackEvents: true - }); - done(); - }); - }); - }); - }); + await client.waitForInitialization(); + await client.variation(flag.key, null, 'c'); - it('generates an event for identify()', done => { - var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); - client.on('ready', () => { - client.identify(defaultUser); expect(eventProcessor.events).toHaveLength(1); var e = eventProcessor.events[0]; expect(e).toMatchObject({ - kind: 'identify', - key: defaultUser.key, - user: defaultUser + kind: 'feature', + key: 'flagkey', + version: 1, + user: null, + variation: null, + value: 'c', + default: 'c', + trackEvents: true }); - done(); }); }); - it('generates an event for track()', done => { + it('generates an event for identify()', async () => { + var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); + await client.waitForInitialization(); + + client.identify(defaultUser); + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'identify', + key: defaultUser.key, + user: defaultUser + }); + }); + + it('generates an event for track()', async () => { var data = { thing: 'stuff' }; var client = stubs.createClient({ eventProcessor: eventProcessor }, {}); - client.on('ready', () => { - client.track('eventkey', defaultUser, data); - expect(eventProcessor.events).toHaveLength(1); - var e = eventProcessor.events[0]; - expect(e).toMatchObject({ - kind: 'custom', - key: 'eventkey', - user: defaultUser, - data: data - }); - done(); + await client.waitForInitialization(); + + client.track('eventkey', defaultUser, data); + expect(eventProcessor.events).toHaveLength(1); + var e = eventProcessor.events[0]; + expect(e).toMatchObject({ + kind: 'custom', + key: 'eventkey', + user: defaultUser, + data: data }); }); }); diff --git a/test/LDClient-test.js b/test/LDClient-test.js index 1c3bb05..fb6de51 100644 --- a/test/LDClient-test.js +++ b/test/LDClient-test.js @@ -63,59 +63,35 @@ describe('LDClient', () => { }); describe('waitUntilReady()', () => { - it('resolves when ready', done => { + it('resolves when ready', async () => { var client = stubs.createClient({}, {}); - client.waitUntilReady().then(done) - .catch(done.error) + await client.waitUntilReady(); }); - it('resolves immediately if the client is already ready', done => { + it('resolves immediately if the client is already ready', async () => { var client = stubs.createClient({}, {}); - client.waitUntilReady().then(() => { - client.waitUntilReady().then(done) - .catch(done.error) - }).catch(done.error); + await client.waitUntilReady(); + await client.waitUntilReady(); }); }); describe('waitForInitialization()', () => { - it('resolves when ready', done => { - var callback = jest.fn(); + it('resolves when ready', async () => { var client = stubs.createClient({}, {}); - - client.waitForInitialization().then(callback) - .then(() => { - expect(callback).toHaveBeenCalled(); - expect(callback.mock.calls[0][0]).toBe(client); - done(); - }).catch(done.error) + await client.waitForInitialization(); }); - it('resolves immediately if the client is already ready', done => { - var callback = jest.fn(); + it('resolves immediately if the client is already ready', async () => { var client = stubs.createClient({}, {}); - - client.waitForInitialization() - .then(() => { - client.waitForInitialization().then(callback) - .then(() => { - expect(callback).toHaveBeenCalled(); - expect(callback.mock.calls[0][0]).toBe(client); - done(); - }).catch(done.error) - }).catch(done.error) + await client.waitForInitialization(); + await client.waitForInitialization(); }); - it('is rejected if initialization fails', done => { + it('is rejected if initialization fails', async () => { var updateProcessor = stubs.stubUpdateProcessor(); updateProcessor.error = { status: 403 }; var client = stubs.createClient({ updateProcessor: updateProcessor }, {}); - - client.waitForInitialization() - .catch(err => { - expect(err).toEqual(updateProcessor.error); - done(); - }); + await expect(client.waitForInitialization()).rejects.toThrow(); }); }); diff --git a/test/async_utils.js b/test/async_utils.js new file mode 100644 index 0000000..1215b15 --- /dev/null +++ b/test/async_utils.js @@ -0,0 +1,15 @@ + +function asyncify(f) { + return new Promise(resolve => f(resolve)); +} + +function sleepAsync(millis) { + return new Promise(resolve => { + setTimeout(resolve, millis); + }); +} + +module.exports = { + asyncify: asyncify, + sleepAsync: sleepAsync +}; diff --git a/test/caching_store_wrapper-test.js b/test/caching_store_wrapper-test.js index e39d68a..0696011 100644 --- a/test/caching_store_wrapper-test.js +++ b/test/caching_store_wrapper-test.js @@ -1,5 +1,7 @@ var CachingStoreWrapper = require('../caching_store_wrapper'); var features = require('../versioned_data_kind').features; +var segments = require('../versioned_data_kind').segments; +const { asyncify, sleepAsync } = require('./async_utils'); function MockCore() { const c = { @@ -57,181 +59,166 @@ function MockCore() { return c; } +function MockOrderedCore() { + const c = { + data: { features: {} }, + + initOrderedInternal: function(newData, cb) { + c.data = newData; + cb(); + }, + // don't bother mocking the rest of the stuff since the wrapper behaves identically except for init + }; + return c; +} + const cacheSeconds = 15; -function runCachedAndUncachedTests(name, testFn) { +function runCachedAndUncachedTests(name, testFn, coreFn) { + var makeCore = coreFn ? coreFn : MockCore; describe(name, function() { - const core1 = MockCore(); + const core1 = makeCore(); const wrapper1 = new CachingStoreWrapper(core1, cacheSeconds); - it('cached', function(done) { testFn(done, wrapper1, core1, true); }); + it('cached', async () => await testFn(wrapper1, core1, true), 1000); - const core2 = MockCore(); + const core2 = makeCore(); const wrapper2 = new CachingStoreWrapper(core2, 0); - it('uncached', function(done) { testFn(done, wrapper2, core2, false); }); + it('uncached', async () => await testFn(wrapper2, core2, false), 1000); }); } -function runCachedTestOnly(name, testFn) { - it(name, function(done) { - const core = MockCore(); +function runCachedTestOnly(name, testFn, coreFn) { + var makeCore = coreFn ? coreFn : MockCore; + it(name, async () => { + const core = makeCore(); const wrapper = new CachingStoreWrapper(core, cacheSeconds); - testFn(done, wrapper, core); + await testFn(wrapper, core); }); } describe('CachingStoreWrapper', function() { - runCachedAndUncachedTests('get()', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('get()', async (wrapper, core, isCached) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2 }; core.forceSet(features, flagv1); - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv1); - - core.forceSet(features, flagv2); // Make a change that bypasses the cache + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv1); - wrapper.get(features, flagv1.key, function(item) { - // If cached, it should return the cached value rather than calling the underlying getter - expect(item).toEqual(isCached ? flagv1 : flagv2); + core.forceSet(features, flagv2); // Make a change that bypasses the cache - done(); - }); - }); + item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + // If cached, it should return the cached value rather than calling the underlying getter + expect(item).toEqual(isCached ? flagv1 : flagv2); }); - runCachedAndUncachedTests('get() with deleted item', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('get() with deleted item', async (wrapper, core, isCached) => { const flagv1 = { key: 'flag', version: 1, deleted: true }; const flagv2 = { key: 'flag', version: 2, deleted: false }; core.forceSet(features, flagv1); - wrapper.get(features, flagv1.key, function(item) { - expect(item).toBe(null); - - core.forceSet(features, flagv2); // Make a change that bypasses the cache + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toBe(null); - wrapper.get(features, flagv2.key, function(item) { - // If cached, the deleted state should persist in the cache - expect(item).toEqual(isCached ? null : flagv2); + core.forceSet(features, flagv2); // Make a change that bypasses the cache - done(); - }); - }); + item = await asyncify(cb => wrapper.get(features, flagv2.key, cb)); + // If cached, the deleted state should persist in the cache + expect(item).toEqual(isCached ? null : flagv2); }); - runCachedAndUncachedTests('get() with missing item', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('get() with missing item', async (wrapper, core, isCached) => { const flag = { key: 'flag', version: 1 }; - wrapper.get(features, flag.key, function(item) { - expect(item).toBe(null); - - core.forceSet(features, flag); + var item = await asyncify(cb => wrapper.get(features, flag.key, cb)); + expect(item).toBe(null); - wrapper.get(features, flag.key, function(item) { - // If cached, the previous null result should persist in the cache - expect(item).toEqual(isCached ? null : flag); + core.forceSet(features, flag); - done(); - }); - }); + item = await asyncify(cb => wrapper.get(features, flag.key, cb)); + // If cached, the previous null result should persist in the cache + expect(item).toEqual(isCached ? null : flag); }); - runCachedTestOnly('cached get() uses values from init()', function(done, wrapper, core) { + runCachedTestOnly('cached get() uses values from init()', async (wrapper, core) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2 }; const allData = { features: { 'flag': flagv1 } }; - wrapper.init(allData, function() { - expect(core.data).toEqual(allData); - - core.forceSet(features, flagv2); + await asyncify(cb => wrapper.init(allData, cb)); + expect(core.data).toEqual(allData); - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv1); + core.forceSet(features, flagv2); - done(); - }); - }); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv1); }); - runCachedAndUncachedTests('all()', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('all()', async (wrapper, core, isCached) => { const flag1 = { key: 'flag1', version: 1 }; const flag2 = { key: 'flag2', version: 1 }; core.forceSet(features, flag1); core.forceSet(features, flag2); - wrapper.all(features, function(items) { - expect(items).toEqual({ 'flag1': flag1, 'flag2': flag2 }); - - core.forceRemove(features, flag2.key); + var items = await asyncify(cb => wrapper.all(features, cb)); + expect(items).toEqual({ 'flag1': flag1, 'flag2': flag2 }); - wrapper.all(features, function(items) { - if (isCached) { - expect(items).toEqual({ 'flag1': flag1, 'flag2': flag2 }); - } else { - expect(items).toEqual({ 'flag1': flag1 }); - } + core.forceRemove(features, flag2.key); - done(); - }); - }); + items = await asyncify(cb => wrapper.all(features, cb)); + if (isCached) { + expect(items).toEqual({ 'flag1': flag1, 'flag2': flag2 }); + } else { + expect(items).toEqual({ 'flag1': flag1 }); + } }); - runCachedAndUncachedTests('all() with deleted item', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('all() with deleted item', async (wrapper, core, isCached) => { const flag1 = { key: 'flag1', version: 1 }; const flag2 = { key: 'flag2', version: 1, deleted: true }; core.forceSet(features, flag1); core.forceSet(features, flag2); - wrapper.all(features, function(items) { - expect(items).toEqual({ 'flag1': flag1 }); - - core.forceRemove(features, flag1.key); + var items = await asyncify(cb => wrapper.all(features, cb)); + expect(items).toEqual({ 'flag1': flag1 }); - wrapper.all(features, function(items) { - if (isCached) { - expect(items).toEqual({ 'flag1': flag1 }); - } else { - expect(items).toEqual({ }); - } + core.forceRemove(features, flag1.key); - done(); - }); - }); + items = await asyncify(cb => wrapper.all(features, cb)); + if (isCached) { + expect(items).toEqual({ 'flag1': flag1 }); + } else { + expect(items).toEqual({ }); + } }); - runCachedAndUncachedTests('all() error condition', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('all() error condition', async (wrapper, core, isCached) => { core.getAllError = true; - wrapper.all(features, function(items) { - expect(items).toBe(null); - done(); - }); + var items = await asyncify(cb => wrapper.all(features, cb)); + expect(items).toBe(null); }); - runCachedTestOnly('cached all() uses values from init()', function(done, wrapper, core) { + runCachedTestOnly('cached all() uses values from init()', async (wrapper, core) => { const flag1 = { key: 'flag1', version: 1 }; const flag2 = { key: 'flag2', version: 1 }; const allData = { features: { flag1: flag1, flag2: flag2 } }; - wrapper.init(allData, function() { - core.forceRemove(features, flag2.key); - - wrapper.all(features, function(items) { - expect(items).toEqual({ flag1: flag1, flag2: flag2 }); + await asyncify(cb => wrapper.init(allData, cb)); + core.forceRemove(features, flag2.key); - done(); - }); - }); + var items = await asyncify(cb => wrapper.all(features, cb)); + expect(items).toEqual({ flag1: flag1, flag2: flag2 }); }); - runCachedTestOnly('cached all() uses fresh values if there has been an update', function(done, wrapper, core) { + runCachedTestOnly('cached all() uses fresh values if there has been an update', async (wrapper, core) => { const flag1v1 = { key: 'flag1', version: 1 }; const flag1v2 = { key: 'flag1', version: 2 }; const flag2v1 = { key: 'flag2', version: 1 }; @@ -239,204 +226,209 @@ describe('CachingStoreWrapper', function() { const allData = { features: { flag1: flag1v1, flag2: flag2v2 } }; - wrapper.init(allData, function() { - expect(core.data).toEqual(allData); - - // make a change to flag1 using the wrapper - this should flush the cache - wrapper.upsert(features, flag1v2, function() { - // make a change to flag2 that bypasses the cache - core.forceSet(features, flag2v2); + await asyncify(cb => wrapper.init(allData, cb)); + expect(core.data).toEqual(allData); - // we should now see both changes since the cache was flushed - wrapper.all(features, function(items) { - expect(items).toEqual({ flag1: flag1v2, flag2: flag2v2 }); + // make a change to flag1 using the wrapper - this should flush the cache + await asyncify(cb => wrapper.upsert(features, flag1v2, cb)); + // make a change to flag2 that bypasses the cache + core.forceSet(features, flag2v2); - done(); - }); - }); - }); + // we should now see both changes since the cache was flushed + var items = await asyncify(cb => wrapper.all(features, cb)); + expect(items).toEqual({ flag1: flag1v2, flag2: flag2v2 }); }); - runCachedAndUncachedTests('upsert() - successful', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('upsert() - successful', async (wrapper, core, isCached) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2 }; - wrapper.upsert(features, flagv1, function() { - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); + await asyncify(cb => wrapper.upsert(features, flagv1, cb)); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); - wrapper.upsert(features, flagv2, function() { - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); + await asyncify(cb => wrapper.upsert(features, flagv2, cb)); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); - // if we have a cache, verify that the new item is now cached by writing a different value - // to the underlying data - get() should still return the cached item - if (isCached) { - const flagv3 = { key: 'flag', version: 3 }; - core.forceSet(features, flagv3); - } - - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv2); + // if we have a cache, verify that the new item is now cached by writing a different value + // to the underlying data - get() should still return the cached item + if (isCached) { + const flagv3 = { key: 'flag', version: 3 }; + core.forceSet(features, flagv3); + } - done(); - }); - }); - }); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv2); }); - runCachedAndUncachedTests('upsert() - error', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('upsert() - error', async (wrapper, core, isCached) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2 }; - wrapper.upsert(features, flagv1, function() { - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); - - core.upsertError = new Error('sorry'); - - wrapper.upsert(features, flagv2, function() { - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); - - // if we have a cache, verify that the old item is still cached by writing a different value - // to the underlying data - get() should still return the cached item - if (isCached) { - const flagv3 = { key: 'flag', version: 3 }; - core.forceSet(features, flagv3); - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv1); - done(); - }); - } else { - done(); - } - }); - }); + await asyncify(cb => wrapper.upsert(features, flagv1, cb)); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); + + core.upsertError = new Error('sorry'); + + await asyncify(cb => wrapper.upsert(features, flagv2, cb)); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv1); + + // if we have a cache, verify that the old item is still cached by writing a different value + // to the underlying data - get() should still return the cached item + if (isCached) { + const flagv3 = { key: 'flag', version: 3 }; + core.forceSet(features, flagv3); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv1); + } }); - runCachedTestOnly('cached upsert() - unsuccessful', function(done, wrapper, core) { + runCachedTestOnly('cached upsert() - unsuccessful', async (wrapper, core) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2 }; core.forceSet(features, flagv2); // this is now in the underlying data, but not in the cache - wrapper.upsert(features, flagv1, function() { - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); // value in store remains the same - - // the cache should now contain flagv2 - check this by making another change that bypasses - // the cache, and verifying that get() uses the cached value instead - const flagv3 = { key: 'flag', version: 3 }; - core.forceSet(features, flagv3); + await asyncify(cb => wrapper.upsert(features, flagv1, cb)); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); // value in store remains the same - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv2); + // the cache should now contain flagv2 - check this by making another change that bypasses + // the cache, and verifying that get() uses the cached value instead + const flagv3 = { key: 'flag', version: 3 }; + core.forceSet(features, flagv3); - done(); - }); - }); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv2); }); - runCachedAndUncachedTests('delete()', function(done, wrapper, core, isCached) { + runCachedAndUncachedTests('delete()', async (wrapper, core, isCached) => { const flagv1 = { key: 'flag', version: 1 }; const flagv2 = { key: 'flag', version: 2, deleted: true }; const flagv3 = { key: 'flag', version: 3 }; core.forceSet(features, flagv1); - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(flagv1); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(flagv1); - wrapper.delete(features, flagv1.key, flagv2.version); + await asyncify(cb => wrapper.delete(features, flagv1.key, flagv2.version, cb)); - expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); + expect(core.data[features.namespace][flagv1.key]).toEqual(flagv2); - // make a change to the flag that bypasses the cache - core.forceSet(features, flagv3); - - wrapper.get(features, flagv1.key, function(item) { - expect(item).toEqual(isCached ? null : flagv3); + // make a change to the flag that bypasses the cache + core.forceSet(features, flagv3); - done(); - }); - }); + var item = await asyncify(cb => wrapper.get(features, flagv1.key, cb)); + expect(item).toEqual(isCached ? null : flagv3); }); describe('initialized()', function() { - it('calls underlying initialized() only if not already inited', function(done) { + it('calls underlying initialized() only if not already inited', async () => { const core = MockCore(); const wrapper = new CachingStoreWrapper(core, 0); - wrapper.initialized(function(value) { - expect(value).toEqual(false); - expect(core.initQueriedCount).toEqual(1); - - core.inited = true; + var value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(false); + expect(core.initQueriedCount).toEqual(1); - wrapper.initialized(function(value) { - expect(value).toEqual(true); - expect(core.initQueriedCount).toEqual(2); + core.inited = true; - core.inited = false; // this should have no effect since we already returned true + value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(true); + expect(core.initQueriedCount).toEqual(2); - wrapper.initialized(function(value) { - expect(value).toEqual(true); - expect(core.initQueriedCount).toEqual(2); + core.inited = false; // this should have no effect since we already returned true - done(); - }); - }); - }); + value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(true); + expect(core.initQueriedCount).toEqual(2); }); - it('will not call initialized() if init() has been called', function(done) { + it('will not call initialized() if init() has been called', async () => { const core = MockCore(); const wrapper = new CachingStoreWrapper(core, 0); - wrapper.initialized(function(value) { - expect(value).toEqual(false); - expect(core.initQueriedCount).toEqual(1); + var value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(false); + expect(core.initQueriedCount).toEqual(1); - const allData = { features: {} }; - wrapper.init(allData, function() { - wrapper.initialized(function(value) { - expect(value).toEqual(true); - expect(core.initQueriedCount).toEqual(1); + const allData = { features: {} }; + await asyncify(cb => wrapper.init(allData, cb)); - done(); - }); - }); - }); + value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(true); + expect(core.initQueriedCount).toEqual(1); }); - it('can cache false result', function(done) { + it('can cache false result', async () => { const core = MockCore(); const wrapper = new CachingStoreWrapper(core, 1); // cache TTL = 1 second - wrapper.initialized(function(value) { - expect(value).toEqual(false); - expect(core.initQueriedCount).toEqual(1); + var value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(false); + expect(core.initQueriedCount).toEqual(1); - core.inited = true; + core.inited = true; - wrapper.initialized(function(value) { - expect(value).toEqual(false); - expect(core.initQueriedCount).toEqual(1); + value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(false); + expect(core.initQueriedCount).toEqual(1); - setTimeout(function() { - wrapper.initialized(function(value) { - expect(value).toEqual(true); - expect(core.initQueriedCount).toEqual(2); - - done(); - }); - }, 1100); - }); - }); + await sleepAsync(1100); + + value = await asyncify(cb => wrapper.initialized(cb)); + expect(value).toEqual(true); + expect(core.initQueriedCount).toEqual(2); }); }); describe('close()', function() { - runCachedAndUncachedTests('closes underlying store', function(done, wrapper, core) { + runCachedAndUncachedTests('closes underlying store', async (wrapper, core) => { wrapper.close(); expect(core.closed).toBe(true); - done(); }); }); + + describe('core that uses initOrdered()', function() { + runCachedAndUncachedTests('receives properly ordered data for init', async (wrapper, core) => { + var dependencyOrderingTestData = {}; + dependencyOrderingTestData[features.namespace] = { + a: { key: "a", prerequisites: [ { key: "b" }, { key: "c" } ] }, + b: { key: "b", prerequisites: [ { key: "c" }, { key: "e" } ] }, + c: { key: "c" }, + d: { key: "d" }, + e: { key: "e" }, + f: { key: "f" } + }; + dependencyOrderingTestData[segments.namespace] = { + o: { key: "o" } + }; + await asyncify(cb => wrapper.init(dependencyOrderingTestData, cb)); + + var receivedData = core.data; + expect(receivedData.length).toEqual(2); + + // Segments should always come first + expect(receivedData[0].kind).toEqual(segments); + expect(receivedData[0].items.length).toEqual(1); + + // Features should be ordered so that a flag always appears after its prerequisites, if any + expect(receivedData[1].kind).toEqual(features); + var featuresMap = dependencyOrderingTestData[features.namespace]; + var featuresList = receivedData[1].items; + expect(featuresList.length).toEqual(Object.keys(featuresMap).length); + for (var itemIndex in featuresList) { + var item = featuresList[itemIndex]; + (item.prerequisites || []).forEach(function(prereq) { + var prereqKey = prereq.key; + var prereqItem = featuresMap[prereqKey]; + var prereqIndex = featuresList.indexOf(prereqItem); + if (prereqIndex > itemIndex) { + var allKeys = featuresList.map(f => f.key); + throw new Error(item.key + " depends on " + prereqKey + ", but " + item.key + + " was listed first; keys in order are [" + allKeys.join(", ") + "]"); + } + }); + } + }, MockOrderedCore); + }); }); diff --git a/test/feature_store_test_base.js b/test/feature_store_test_base.js index f253e0b..911f1da 100644 --- a/test/feature_store_test_base.js +++ b/test/feature_store_test_base.js @@ -1,4 +1,5 @@ var dataKind = require('../versioned_data_kind'); +const { asyncify } = require('./async_utils'); // The following tests should be run on every feature store implementation. If this type of // store supports caching, the tests should be run once with caching enabled and once with @@ -21,7 +22,7 @@ function baseFeatureStoreTests(makeStore, clearExistingData, isCached) { version: 10 }; - beforeEach(function(done) { + beforeEach(done => { if (clearExistingData) { clearExistingData(done); } else { @@ -29,28 +30,24 @@ function baseFeatureStoreTests(makeStore, clearExistingData, isCached) { } }); - function initedStore(cb) { + async function initedStore() { var store = makeStore(); var initData = {}; initData[dataKind.features.namespace] = { 'foo': feature1, 'bar': feature2 }; - store.init(initData, function() { - cb(store); - }); + await asyncify(cb => store.init(initData, cb)); + return store; } - it('is initialized after calling init()', function(done) { - initedStore(function(store) { - store.initialized(function(result) { - expect(result).toBe(true); - done(); - }); - }); + it('is initialized after calling init()', async () => { + var store = await initedStore(); + var result = await asyncify(cb => store.initialized(cb)); + expect(result).toBe(true); }); - it('init() completely replaces previous data', function(done) { + it('init() completely replaces previous data', async () => { var store = makeStore(); var flags = { first: { key: 'first', version: 1 }, @@ -61,49 +58,37 @@ function baseFeatureStoreTests(makeStore, clearExistingData, isCached) { initData[dataKind.features.namespace] = flags; initData[dataKind.segments.namespace] = segments; - store.init(initData, function() { - store.all(dataKind.features, function(items) { - expect(items).toEqual(flags); - store.all(dataKind.segments, function(items) { - expect(items).toEqual(segments); - - var newFlags = { first: { key: 'first', version: 3 } }; - var newSegments = { first: { key: 'first', version: 4 } }; - var initData = {}; - initData[dataKind.features.namespace] = newFlags; - initData[dataKind.segments.namespace] = newSegments; - - store.init(initData, function() { - store.all(dataKind.features, function(items) { - expect(items).toEqual(newFlags); - store.all(dataKind.segments, function(items) { - expect(items).toEqual(newSegments); - - done(); - }) - }) - }); - }); - }); - }); + await asyncify(cb => store.init(initData, cb)); + var items = await asyncify(cb => store.all(dataKind.features, cb)); + expect(items).toEqual(flags); + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(items).toEqual(segments); + + var newFlags = { first: { key: 'first', version: 3 } }; + var newSegments = { first: { key: 'first', version: 4 } }; + var initData = {}; + initData[dataKind.features.namespace] = newFlags; + initData[dataKind.segments.namespace] = newSegments; + + await asyncify(cb => store.init(initData, cb)); + items = await asyncify(cb => store.all(dataKind.features, cb)); + expect(items).toEqual(newFlags); + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(items).toEqual(newSegments); }); if (!isCached && clearExistingData) { function testInitStateDetection(desc, initData) { - it(desc, function(done) { + it(desc, async () => { var store1 = makeStore(); var store2 = makeStore(); - store1.initialized(function(result) { - expect(result).toBe(false); + var result = await asyncify(cb => store1.initialized(cb)); + expect(result).toBe(false); - store2.init(initData, function() { - store1.initialized(function(result) { - expect(result).toBe(true); - done(); - }); - }); - }); + await asyncify(cb => store2.init(initData, cb)); + result = await asyncify(cb => store1.initialized(cb)); + expect(result).toBe(true); }); } @@ -114,76 +99,56 @@ function baseFeatureStoreTests(makeStore, clearExistingData, isCached) { { features: {} }); } - it('gets existing feature', function(done) { - initedStore(function(store) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).toEqual(feature1); - done(); - }); - }); + it('gets existing feature', async () => { + var store = await initedStore(); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).toEqual(feature1); }); - it('does not get nonexisting feature', function(done) { - initedStore(function(store) { - store.get(dataKind.features, 'biz', function(result) { - expect(result).toBe(null); - done(); - }); - }); + it('does not get nonexisting feature', async () => { + var store = await initedStore(); + var result = await asyncify(cb => store.get(dataKind.features, 'biz', cb)); + expect(result).toBe(null); }); - it('gets all features', function(done) { - initedStore(function(store) { - store.all(dataKind.features, function(result) { - expect(result).toEqual({ - 'foo': feature1, - 'bar': feature2 - }); - done(); - }); + it('gets all features', async () => { + var store = await initedStore(); + var result = await asyncify(cb => store.all(dataKind.features, cb)); + expect(result).toEqual({ + 'foo': feature1, + 'bar': feature2 }); }); - it('upserts with newer version', function(done) { + it('upserts with newer version', async () => { var newVer = { key: feature1.key, version: feature1.version + 1 }; - initedStore(function(store) { - store.upsert(dataKind.features, newVer, function(result) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).toEqual(newVer); - done(); - }); - }); - }); + var store = await initedStore(); + await asyncify(cb => store.upsert(dataKind.features, newVer, cb)); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).toEqual(newVer); }); - it('does not upsert with older version', function(done) { + it('does not upsert with older version', async () => { var oldVer = { key: feature1.key, version: feature1.version - 1 }; - initedStore(function(store) { - store.upsert(dataKind.features, oldVer, function(result) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).toEqual(feature1); - done(); - }); - }); - }); + var store = await initedStore(); + await asyncify(cb => store.upsert(dataKind.features, oldVer, cb)); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).toEqual(feature1); }); - it('upserts new feature', function(done) { + it('upserts new feature', async () => { var newFeature = { key: 'biz', version: 99 }; - initedStore(function(store) { - store.upsert(dataKind.features, newFeature, function(result) { - store.get(dataKind.features, newFeature.key, function(result) { - expect(result).toEqual(newFeature); - done(); - }); - }); - }); + var store = await initedStore(); + await asyncify(cb => store.upsert(dataKind.features, newFeature, cb)); + var result = await asyncify(cb => store.get(dataKind.features, newFeature.key, cb)); + expect(result).toEqual(newFeature); }); - it('handles upsert race condition within same client correctly', function(done) { + it('handles upsert race condition within same client correctly', done => { + // Not sure if there is a way to do this one with async/await var ver1 = { key: feature1.key, version: feature1.version + 1 }; var ver2 = { key: feature1.key, version: feature1.version + 2 }; - initedStore(function(store) { + initedStore().then(store => { var counter = 0; var combinedCallback = function() { counter++; @@ -201,50 +166,33 @@ function baseFeatureStoreTests(makeStore, clearExistingData, isCached) { }); }); - it('deletes with newer version', function(done) { - initedStore(function(store) { - store.delete(dataKind.features, feature1.key, feature1.version + 1, function(result) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).toBe(null); - done(); - }); - }); - }); + it('deletes with newer version', async () => { + var store = await initedStore(); + await asyncify(cb => store.delete(dataKind.features, feature1.key, feature1.version + 1, cb)); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).toBe(null); }); - it('does not delete with older version', function(done) { - initedStore(function(store) { - store.delete(dataKind.features, feature1.key, feature1.version - 1, function(result) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).not.toBe(null); - done(); - }); - }); - }); + it('does not delete with older version', async () => { + var store = await initedStore(); + await asyncify(cb => store.delete(dataKind.features, feature1.key, feature1.version - 1, cb)); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).not.toBe(null); }); - it('allows deleting unknown feature', function(done) { - initedStore(function(store) { - store.delete(dataKind.features, 'biz', 99, function(result) { - store.get(dataKind.features, 'biz', function(result) { - expect(result).toBe(null); - done(); - }); - }); - }); + it('allows deleting unknown feature', async () => { + var store = await initedStore(); + await asyncify(cb => store.delete(dataKind.features, 'biz', 99, cb)); + var result = await asyncify(cb => store.get(dataKind.features, 'biz', cb)); + expect(result).toBe(null); }); - it('does not upsert older version after delete', function(done) { - initedStore(function(store) { - store.delete(dataKind.features, feature1.key, feature1.version + 1, function(result) { - store.upsert(dataKind.features, feature1, function(result) { - store.get(dataKind.features, feature1.key, function(result) { - expect(result).toBe(null); - done(); - }); - }); - }); - }); + it('does not upsert older version after delete', async () => { + var store = await initedStore(); + await asyncify(cb => store.delete(dataKind.features, feature1.key, feature1.version + 1, cb)); + await asyncify(cb => store.upsert(dataKind.features, feature1, cb)); + var result = await asyncify(cb => store.get(dataKind.features, feature1.key, cb)); + expect(result).toBe(null); }); } @@ -268,10 +216,10 @@ function concurrentModificationTests(makeStore, makeStoreWithHook) { return { key: flagKey, version: v }; } - function withInitedStore(store, cb) { + async function initStore(store) { var allData = { features: {} }; allData['features'][flagKey] = makeFlagWithVersion(initialVersion); - store.init(allData, cb); + await asyncify(cb => store.init(allData, cb)); } function writeCompetingVersions(flagVersionsToWrite) { @@ -287,36 +235,28 @@ function concurrentModificationTests(makeStore, makeStoreWithHook) { }; } - it('handles upsert race condition against other client with lower version', function(done) { + it('handles upsert race condition against other client with lower version', async () => { var myDesiredVersion = 10; var competingStoreVersions = [ 2, 3, 4 ]; // proves that we can retry multiple times if necessary var myStore = makeStoreWithHook(writeCompetingVersions(competingStoreVersions)); - withInitedStore(myStore, function() { - myStore.upsert(dataKind.features, makeFlagWithVersion(myDesiredVersion), function() { - myStore.get(dataKind.features, flagKey, function(result) { - expect(result.version).toEqual(myDesiredVersion); - done(); - }); - }); - }); + await initStore(myStore); + await asyncify(cb => myStore.upsert(dataKind.features, makeFlagWithVersion(myDesiredVersion), cb)); + var result = await asyncify(cb => myStore.get(dataKind.features, flagKey, cb)); + expect(result.version).toEqual(myDesiredVersion); }); - it('handles upsert race condition against other client with higher version', function(done) { + it('handles upsert race condition against other client with higher version', async () => { var myDesiredVersion = 2; var competingStoreVersion = 3; var myStore = makeStoreWithHook(writeCompetingVersions([ competingStoreVersion ])); - withInitedStore(myStore, function() { - myStore.upsert(dataKind.features, makeFlagWithVersion(myDesiredVersion), function() { - myStore.get(dataKind.features, flagKey, function(result) { - expect(result.version).toEqual(competingStoreVersion); - done(); - }); - }); - }); + await initStore(myStore); + await asyncify(cb => myStore.upsert(dataKind.features, makeFlagWithVersion(myDesiredVersion), cb)); + var result = await asyncify(cb => myStore.get(dataKind.features, flagKey, cb)); + expect(result.version).toEqual(competingStoreVersion); }); } diff --git a/test/file_data_source-test.js b/test/file_data_source-test.js new file mode 100644 index 0000000..e5a9cc4 --- /dev/null +++ b/test/file_data_source-test.js @@ -0,0 +1,256 @@ +var fs = require('fs'); +var tmp = require('tmp'); +var dataKind = require('../versioned_data_kind'); +const { asyncify, sleepAsync } = require('./async_utils'); + +var LaunchDarkly = require('../index'); +var FileDataSource = require('../file_data_source'); +var InMemoryFeatureStore = require('../feature_store'); + +var flag1Key = 'flag1'; +var flag2Key = 'flag2'; +var flag2Value = 'value2'; +var segment1Key = 'seg1'; + +var flag1 = { + "key": flag1Key, + "on": true, + "fallthrough": { + "variation": 2 + }, + "variations": [ "fall", "off", "on" ] +}; + +var segment1 = { + "key": segment1Key, + "include": ["user1"] +}; + +var flagOnlyJson = ` +{ + "flags": { + "${flag1Key}": ${ JSON.stringify(flag1) } + } +}`; + +var segmentOnlyJson = ` +{ + "segments": { + "${segment1Key}": ${ JSON.stringify(segment1) } + } +}`; + +var allPropertiesJson = ` +{ + "flags": { + "${flag1Key}": ${ JSON.stringify(flag1) } + }, + "flagValues": { + "${flag2Key}": "${flag2Value}" + }, + "segments": { + "${segment1Key}": ${ JSON.stringify(segment1) } + } +}`; + +var allPropertiesYaml = ` +flags: + ${flag1Key}: + key: ${flag1Key} + on: true + fallthrough: + variation: 2 + variations: + - fall + - off + - on +flagValues: + ${flag2Key}: "${flag2Value}" +segments: + ${segment1Key}: + key: ${segment1Key} + include: + - user1 +`; + +describe('FileDataSource', function() { + var store; + var dataSources = []; + + beforeEach(() => { + store = InMemoryFeatureStore(); + dataSources = []; + }); + + afterEach(() => { + dataSources.forEach(s => s.close()); + }); + + function makeTempFile(content) { + return new Promise((resolve, reject) => { + tmp.file(function(err, path, fd) { + if (err) { + reject(err); + } else { + replaceFileContent(path, content).then(() => resolve(path)); + } + }); + }); + } + + function replaceFileContent(path, content) { + return new Promise((resolve, reject) => { + fs.writeFile(path, content, function(err) { + err ? reject(err) : resolve(); + }); + }); + } + + function setupDataSource(options) { + var factory = FileDataSource(options); + var ds = factory({ featureStore: store }); + dataSources.push(ds); + return ds; + } + + function sorted(a) { + var a1 = Array.from(a); + a1.sort(); + return a1; + } + + it('does not load flags prior to start', async () => { + var path = await makeTempFile('{"flagValues":{"key":"value"}}'); + var fds = setupDataSource({ paths: [path] }); + + expect(fds.initialized()).toBe(false); + expect(await asyncify(cb => store.initialized(cb))).toBe(false); + expect(await asyncify(cb => store.all(dataKind.features, cb))).toEqual({}); + expect(await asyncify(cb => store.all(dataKind.segments, cb))).toEqual({}); + }); + + async function testLoadAllProperties(content) { + var path = await makeTempFile(content); + var fds = setupDataSource({ paths: [path] }); + await asyncify(fds.start); + + expect(fds.initialized()).toBe(true); + expect(await asyncify(cb => store.initialized(cb))).toBe(true); + var items = await asyncify(cb => store.all(dataKind.features, cb)); + expect(sorted(Object.keys(items))).toEqual([ flag1Key, flag2Key ]); + var flag = await asyncify(cb => store.get(dataKind.features, flag1Key, cb)); + expect(flag).toEqual(flag1); + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(items).toEqual({ seg1: segment1 }); + } + + it('loads flags on start - from JSON', () => testLoadAllProperties(allPropertiesJson)); + + it('loads flags on start - from YAML', () => testLoadAllProperties(allPropertiesYaml)); + + it('does not load if file is missing', async () => { + var fds = setupDataSource({ paths: ['no-such-file'] }); + await asyncify(fds.start); + + expect(fds.initialized()).toBe(false); + expect(await asyncify(cb => store.initialized(cb))).toBe(false); + }); + + it('does not load if file data is malformed', async () => { + var path = await makeTempFile('{x'); + var fds = setupDataSource({ paths: [path] }); + await asyncify(fds.start); + + expect(fds.initialized()).toBe(false); + expect(await asyncify(cb => store.initialized(cb))).toBe(false); + }); + + it('can load multiple files', async () => { + var path1 = await makeTempFile(flagOnlyJson); + var path2 = await makeTempFile(segmentOnlyJson); + var fds = setupDataSource({ paths: [path1, path2] }); + await asyncify(fds.start); + + expect(fds.initialized()).toBe(true); + expect(await asyncify(cb => store.initialized(cb))).toBe(true); + + var items = await asyncify(cb => store.all(dataKind.features, cb)); + expect(Object.keys(items)).toEqual([ flag1Key ]); + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(Object.keys(items)).toEqual([ segment1Key ]); + }); + + it('does not allow duplicate keys', async () => { + var path1 = await makeTempFile(flagOnlyJson); + var path2 = await makeTempFile(flagOnlyJson); + var fds = setupDataSource({ paths: [path1, path2] }); + await asyncify(fds.start); + + expect(fds.initialized()).toBe(false); + expect(await asyncify(cb => store.initialized(cb))).toBe(false); + }); + + it('does not reload modified file if auto-update is off', async () => { + var path = await makeTempFile(flagOnlyJson); + var fds = setupDataSource({ paths: [path] }); + await asyncify(fds.start); + + var items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(Object.keys(items).length).toEqual(0); + + await sleepAsync(200); + await replaceFileContent(path, segmentOnlyJson); + await sleepAsync(200); + + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(Object.keys(items).length).toEqual(0); + }); + + it('reloads modified file if auto-update is on', async () => { + var path = await makeTempFile(flagOnlyJson); + var fds = setupDataSource({ paths: [path], autoUpdate: true }); + await asyncify(fds.start); + + var items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(Object.keys(items).length).toEqual(0); + + await sleepAsync(200); + await replaceFileContent(path, segmentOnlyJson); + await sleepAsync(200); + + items = await asyncify(cb => store.all(dataKind.segments, cb)); + expect(Object.keys(items).length).toEqual(1); + }); + + it('evaluates simplified flag with client as expected', async () => { + var path = await makeTempFile(allPropertiesJson); + var factory = FileDataSource({ paths: [ path ]}); + var config = { updateProcessor: factory, sendEvents: false }; + var client = LaunchDarkly.init('dummy-key', config); + var user = { key: 'userkey' }; + + try { + await client.waitForInitialization(); + var result = await client.variation(flag2Key, user, ''); + expect(result).toEqual(flag2Value); + } finally { + client.close(); + } + }); + + it('evaluates full flag with client as expected', async () => { + var path = await makeTempFile(allPropertiesJson); + var factory = FileDataSource({ paths: [ path ]}); + var config = { updateProcessor: factory, sendEvents: false }; + var client = LaunchDarkly.init('dummy-key', config); + var user = { key: 'userkey' }; + + try { + await client.waitForInitialization(); + var result = await client.variation(flag1Key, user, ''); + expect(result).toEqual('on'); + } finally { + client.close(); + } + }); +}); \ No newline at end of file diff --git a/test/streaming-test.js b/test/streaming-test.js index 10996d1..1c0b47d 100644 --- a/test/streaming-test.js +++ b/test/streaming-test.js @@ -1,6 +1,7 @@ var InMemoryFeatureStore = require('../feature_store'); var StreamProcessor = require('../streaming'); var dataKind = require('../versioned_data_kind'); +const { asyncify, sleepAsync } = require('./async_utils'); describe('StreamProcessor', function() { var sdkKey = 'SDK_KEY'; @@ -24,13 +25,10 @@ describe('StreamProcessor', function() { }; } - function expectJsonError(config, done) { - return function(err) { - expect(err).not.toBe(undefined); - expect(err.message).toEqual('Malformed JSON data in event stream'); - expect(config.logger.error).toHaveBeenCalled(); - done(); - } + function expectJsonError(err, config) { + expect(err).not.toBe(undefined); + expect(err.message).toEqual('Malformed JSON data in event stream'); + expect(config.logger.error).toHaveBeenCalled(); } it('uses expected URL', function() { @@ -62,7 +60,7 @@ describe('StreamProcessor', function() { } }; - it('causes flags and segments to be stored', function(done) { + it('causes flags and segments to be stored', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -71,47 +69,42 @@ describe('StreamProcessor', function() { es.handlers.put({ data: JSON.stringify(putData) }); - featureStore.initialized(function(flag) { - expect(flag).toEqual(true); - }); - - featureStore.get(dataKind.features, 'flagkey', function(f) { - expect(f.version).toEqual(1); - featureStore.get(dataKind.segments, 'segkey', function(s) { - expect(s.version).toEqual(2); - done(); - }); - }); + var flag = await asyncify(cb => featureStore.initialized(cb)); + expect(flag).toEqual(true); + + var f = await asyncify(cb => featureStore.get(dataKind.features, 'flagkey', cb)); + expect(f.version).toEqual(1); + var s = await asyncify(cb => featureStore.get(dataKind.segments, 'segkey', cb)); + expect(s.version).toEqual(2); }); - it('calls initialization callback', function(done) { + it('calls initialization callback', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); var sp = StreamProcessor(sdkKey, config, null, es.constructor); - var cb = function(err) { - expect(err).toBe(undefined); - done(); - } - - sp.start(cb); + var waitUntilStarted = asyncify(cb => sp.start(cb)); es.handlers.put({ data: JSON.stringify(putData) }); + var result = await waitUntilStarted; + expect(result).toBe(undefined); }); - it('passes error to callback if data is invalid', function(done) { + it('passes error to callback if data is invalid', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); var sp = StreamProcessor(sdkKey, config, null, es.constructor); - sp.start(expectJsonError(config, done)); + var waitUntilStarted = asyncify(cb => sp.start(cb)); es.handlers.put({ data: '{not-good' }); + var result = await waitUntilStarted; + expectJsonError(result, config); }); }); describe('patch message', function() { - it('updates flag', function(done) { + it('updates flag', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -125,13 +118,11 @@ describe('StreamProcessor', function() { sp.start(); es.handlers.patch({ data: JSON.stringify(patchData) }); - featureStore.get(dataKind.features, 'flagkey', function(f) { - expect(f.version).toEqual(1); - done(); - }); + var f = await asyncify(cb => featureStore.get(dataKind.features, 'flagkey', cb)); + expect(f.version).toEqual(1); }); - it('updates segment', function(done) { + it('updates segment', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -145,25 +136,25 @@ describe('StreamProcessor', function() { sp.start(); es.handlers.patch({ data: JSON.stringify(patchData) }); - featureStore.get(dataKind.segments, 'segkey', function(s) { - expect(s.version).toEqual(1); - done(); - }); + var s = await asyncify(cb => featureStore.get(dataKind.segments, 'segkey', cb)); + expect(s.version).toEqual(1); }); - it('passes error to callback if data is invalid', function(done) { + it('passes error to callback if data is invalid', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); var sp = StreamProcessor(sdkKey, config, null, es.constructor); - sp.start(expectJsonError(config, done)); + var waitForCallback = asyncify(cb => sp.start(cb)); es.handlers.patch({ data: '{not-good' }); + var result = await waitForCallback; + expectJsonError(result, config); }); }); describe('delete message', function() { - it('deletes flag', function(done) { + it('deletes flag', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -172,22 +163,18 @@ describe('StreamProcessor', function() { sp.start(); var flag = { key: 'flagkey', version: 1 } - featureStore.upsert(dataKind.features, flag, function() { - featureStore.get(dataKind.features, flag.key, function(f) { - expect(f).toEqual(flag); - - var deleteData = { path: '/flags/' + flag.key, version: 2 }; - es.handlers.delete({ data: JSON.stringify(deleteData) }); - - featureStore.get(dataKind.features, flag.key, function(f) { - expect(f).toBe(null); - done(); - }) - }); - }); + await asyncify(cb => featureStore.upsert(dataKind.features, flag, cb)); + var f = await asyncify(cb => featureStore.get(dataKind.features, flag.key, cb)); + expect(f).toEqual(flag); + + var deleteData = { path: '/flags/' + flag.key, version: 2 }; + es.handlers.delete({ data: JSON.stringify(deleteData) }); + + var f = await asyncify(cb => featureStore.get(dataKind.features, flag.key, cb)); + expect(f).toBe(null); }); - it('deletes segment', function(done) { + it('deletes segment', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -196,29 +183,27 @@ describe('StreamProcessor', function() { sp.start(); var segment = { key: 'segkey', version: 1 } - featureStore.upsert(dataKind.segments, segment, function() { - featureStore.get(dataKind.segments, segment.key, function(s) { - expect(s).toEqual(segment); - - var deleteData = { path: '/segments/' + segment.key, version: 2 }; - es.handlers.delete({ data: JSON.stringify(deleteData) }); - - featureStore.get(dataKind.segments, segment.key, function(s) { - expect(s).toBe(null); - done(); - }) - }); - }); + await asyncify(cb => featureStore.upsert(dataKind.segments, segment, cb)); + var s = await asyncify(cb => featureStore.get(dataKind.segments, segment.key, cb)); + expect(s).toEqual(segment); + + var deleteData = { path: '/segments/' + segment.key, version: 2 }; + es.handlers.delete({ data: JSON.stringify(deleteData) }); + + s = await asyncify(cb => featureStore.get(dataKind.segments, segment.key, cb)); + expect(s).toBe(null); }); - it('passes error to callback if data is invalid', function(done) { + it('passes error to callback if data is invalid', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); var sp = StreamProcessor(sdkKey, config, null, es.constructor); - sp.start(expectJsonError(config, done)); + var waitForResult = asyncify(cb => sp.start(cb)); es.handlers.delete({ data: '{not-good' }); + var result = await waitForResult; + expectJsonError(result, config); }); }); @@ -237,7 +222,7 @@ describe('StreamProcessor', function() { } }; - it('requests and stores flags and segments', function(done) { + it('requests and stores flags and segments', async () => { var featureStore = InMemoryFeatureStore(); var config = { featureStore: featureStore, logger: fakeLogger() }; var es = fakeEventSource(); @@ -247,23 +232,18 @@ describe('StreamProcessor', function() { es.handlers['indirect/put']({}); - setImmediate(function() { - featureStore.get(dataKind.features, 'flagkey', function(f) { - expect(f.version).toEqual(1); - featureStore.get(dataKind.segments, 'segkey', function(s) { - expect(s.version).toEqual(2); - featureStore.initialized(function(flag) { - expect(flag).toBe(true); - }); - done(); - }); - }); - }); + await sleepAsync(0); + var f = await asyncify(cb => featureStore.get(dataKind.features, 'flagkey', cb)); + expect(f.version).toEqual(1); + var s = await asyncify(cb => featureStore.get(dataKind.segments, 'segkey', cb)); + expect(s.version).toEqual(2); + var value = await asyncify(cb => featureStore.initialized(cb)); + expect(value).toBe(true); }); }); describe('indirect patch message', function() { - it('requests and updates flag', function(done) { + it('requests and updates flag', async () => { var flag = { key: 'flagkey', version: 1 }; var fakeRequestor = { requestObject: function(kind, key, cb) { @@ -282,15 +262,12 @@ describe('StreamProcessor', function() { es.handlers['indirect/patch']({ data: '/flags/flagkey' }); - setImmediate(function() { - featureStore.get(dataKind.features, 'flagkey', function(f) { - expect(f.version).toEqual(1); - done(); - }); - }); + await sleepAsync(0); + var f = await asyncify(cb => featureStore.get(dataKind.features, 'flagkey', cb)); + expect(f.version).toEqual(1); }); - it('requests and updates segment', function(done) { + it('requests and updates segment', async () => { var segment = { key: 'segkey', version: 1 }; var fakeRequestor = { requestObject: function(kind, key, cb) { @@ -309,12 +286,9 @@ describe('StreamProcessor', function() { es.handlers['indirect/patch']({ data: '/segments/segkey' }); - setImmediate(function() { - featureStore.get(dataKind.segments, 'segkey', function(s) { - expect(s.version).toEqual(1); - done(); - }); - }); + await sleepAsync(0); + var s = await asyncify(cb => featureStore.get(dataKind.segments, 'segkey', cb)); + expect(s.version).toEqual(1); }); }); }); diff --git a/versioned_data_kind.js b/versioned_data_kind.js index 21834ca..d71b2c3 100644 --- a/versioned_data_kind.js +++ b/versioned_data_kind.js @@ -12,13 +12,21 @@ var features = { namespace: 'features', streamApiPath: '/flags/', - requestPath: '/sdk/latest-flags/' + requestPath: '/sdk/latest-flags/', + priority: 1, + getDependencyKeys: function(flag) { + if (!flag.prerequisites || !flag.prerequisites.length) { + return []; + } + return flag.prerequisites.map(function(p) { return p.key; }); + } }; var segments = { namespace: 'segments', streamApiPath: '/segments/', - requestPath: '/sdk/latest-segments/' + requestPath: '/sdk/latest-segments/', + priority: 0 }; module.exports = { From 5da884631e1baf4311414954ff88f789aadd6316 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Sat, 12 Jan 2019 00:55:11 +0000 Subject: [PATCH 2/3] Update Changelog for release of version 5.7.0 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c4517..964f7e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the LaunchDarkly Node.js SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.7.0] - 2019-01-11 +### Added: +- It is now possible to inject feature flags into the client from local JSON or YAML files, replacing the normal LaunchDarkly connection. This would typically be for testing purposes. See `FileDataSource` in the [TypeScript API documentation](https://github.com/launchdarkly/node-client/blob/master/index.d.ts), and ["Reading flags from a file"](https://docs.launchdarkly.com/v2.0/docs/reading-flags-from-a-file). + +### Fixed: +- Fixed a potential race condition that could happen when using a DynamoDB or Consul feature store. The Redis feature store was not affected. + ## [5.6.2] - 2018-11-15 ### Fixed: - Creating multiple clients with the default in-memory feature store (i.e. leaving `config.featureStore` unset) was causing all of the clients to share the _same_ feature store instance. This has been fixed so they will now each get their own in-memory store. (Thanks, [seanparmelee](https://github.com/launchdarkly/node-client/pull/130)!) From 5d439400aec6024ea67ccb7bd1241857f81f5426 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Sat, 12 Jan 2019 00:55:17 +0000 Subject: [PATCH 3/3] Preparing for release of version 5.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4625be9..36631c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ldclient-node", - "version": "5.6.2", + "version": "5.7.0", "description": "LaunchDarkly SDK for Node.js", "main": "index.js", "scripts": {