diff --git a/package-lock.json b/package-lock.json index d763f46..4b58a61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1504,6 +1504,19 @@ "@types/yargs": "^13.0.0" } }, + "@rollup/plugin-node-resolve": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-6.0.0.tgz", + "integrity": "sha512-GqWz1CfXOsqpeVMcoM315+O7zMxpRsmhWyhJoxLFHVSp9S64/u02i7len/FnbTNbmgYs+sZyilasijH8UiuboQ==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.0", + "@types/resolve": "0.0.8", + "builtin-modules": "^3.1.0", + "is-module": "^1.0.0", + "resolve": "^1.11.1" + } + }, "@rollup/plugin-replace": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.2.0.tgz", @@ -1523,42 +1536,15 @@ } } }, - "@sinonjs/commons": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.4.0.tgz", - "integrity": "sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw==", - "dev": true, - "requires": { - "type-detect": "4.0.8" - } - }, - "@sinonjs/formatio": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.1.tgz", - "integrity": "sha512-tsHvOB24rvyvV2+zKMmPkZ7dXX6LSLKZ7aOtXY6Edklp0uRcgGpOsQTTGTcWViFyx4uhWc6GV8QdnALbIbIdeQ==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1", - "@sinonjs/samsam": "^3.1.0" - } - }, - "@sinonjs/samsam": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.2.0.tgz", - "integrity": "sha512-j5F1rScewLtx6pbTK0UAjA3jJj4RYiSKOix53YWv+Jzy/AZ69qHxUpU8fwVLjyKbEEud9QrLpv6Ggs7WqTimYw==", + "@rollup/pluginutils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.0.0.tgz", + "integrity": "sha512-qBbGQQaUUiId/lBU9VMeYlVLOoRNvz1fV8HWY5tiGDpI2gdPZHbmOfCjzSdXPhdq3XOfyWvXEBlIPbnM3+9ogQ==", "dev": true, "requires": { - "@sinonjs/commons": "^1.0.2", - "array-from": "^2.1.1", - "lodash": "^4.17.11" + "estree-walker": "^0.6.1" } }, - "@sinonjs/text-encoding": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", - "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", - "dev": true - }, "@types/babel__core": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.3.tgz", @@ -1673,23 +1659,6 @@ "integrity": "sha1-X6rZwsB/YN12dw9xzwJbYqY8/U4=", "dev": true }, - "abstract-leveldown": { - "version": "0.12.4", - "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-0.12.4.tgz", - "integrity": "sha1-KeGOYy5g5OIh1YECR4UqY9ey5BA=", - "dev": true, - "requires": { - "xtend": "~3.0.0" - }, - "dependencies": { - "xtend": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", - "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=", - "dev": true - } - } - }, "acorn": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", @@ -2152,12 +2121,6 @@ "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", "dev": true }, - "array-from": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", - "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", - "dev": true - }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -2194,17 +2157,6 @@ "safer-buffer": "~2.1.0" } }, - "asn1.js": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", - "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", - "dev": true, - "requires": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -2472,47 +2424,6 @@ "dev": true, "optional": true }, - "bl": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/bl/-/bl-0.8.2.tgz", - "integrity": "sha1-yba8oI0bwuoA/Ir7Txpf0eHGbk4=", - "dev": true, - "requires": { - "readable-stream": "~1.0.26" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - } - } - }, - "bn.js": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", - "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", - "dev": true - }, "boxen": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.1.0.tgz", @@ -2637,12 +2548,6 @@ } } }, - "brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", - "dev": true - }, "brotli-size": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/brotli-size/-/brotli-size-4.0.0.tgz", @@ -2675,79 +2580,6 @@ } } }, - "browserify-aes": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", - "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", - "dev": true, - "requires": { - "buffer-xor": "^1.0.3", - "cipher-base": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.3", - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "browserify-cipher": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", - "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", - "dev": true, - "requires": { - "browserify-aes": "^1.0.4", - "browserify-des": "^1.0.0", - "evp_bytestokey": "^1.0.0" - } - }, - "browserify-des": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", - "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "des.js": "^1.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, - "browserify-fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browserify-fs/-/browserify-fs-1.0.0.tgz", - "integrity": "sha1-8HWqinKdTRcW0GZiDjhvzBMRqW8=", - "dev": true, - "requires": { - "level-filesystem": "^1.0.1", - "level-js": "^2.1.3", - "levelup": "^0.18.2" - } - }, - "browserify-rsa": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", - "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "randombytes": "^2.0.1" - } - }, - "browserify-sign": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", - "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", - "dev": true, - "requires": { - "bn.js": "^4.1.1", - "browserify-rsa": "^4.0.0", - "create-hash": "^1.1.0", - "create-hmac": "^1.1.2", - "elliptic": "^6.0.0", - "inherits": "^2.0.1", - "parse-asn1": "^5.0.0" - } - }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -2757,24 +2589,12 @@ "node-int64": "^0.4.0" } }, - "buffer-es6": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/buffer-es6/-/buffer-es6-4.9.3.tgz", - "integrity": "sha1-8mNHuC33b9N+GLy1KIxJcM/VxAQ=", - "dev": true - }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", "dev": true }, - "buffer-xor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", - "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", - "dev": true - }, "builtin-modules": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz", @@ -2933,6 +2753,7 @@ "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", "dev": true, + "optional": true, "requires": { "is-extendable": "^0.1.0" } @@ -2977,7 +2798,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", - "dev": true + "dev": true, + "optional": true }, "is-glob": { "version": "4.0.1", @@ -3021,16 +2843,6 @@ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "dev": true }, - "cipher-base": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", - "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "circular-json": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", @@ -3126,12 +2938,6 @@ } } }, - "clone": { - "version": "0.1.19", - "resolved": "https://registry.npmjs.org/clone/-/clone-0.1.19.tgz", - "integrity": "sha1-YT+2hjmyaklKxTJT4Vsaa9iK2oU=", - "dev": true - }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3275,43 +3081,6 @@ "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true }, - "create-ecdh": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", - "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "elliptic": "^6.0.0" - } - }, - "create-hash": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", - "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.1", - "inherits": "^2.0.1", - "md5.js": "^1.3.4", - "ripemd160": "^2.0.1", - "sha.js": "^2.4.0" - } - }, - "create-hmac": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", - "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", - "dev": true, - "requires": { - "cipher-base": "^1.0.3", - "create-hash": "^1.1.0", - "inherits": "^2.0.1", - "ripemd160": "^2.0.0", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, "cross-env": { "version": "5.1.4", "resolved": "http://registry.npmjs.org/cross-env/-/cross-env-5.1.4.tgz", @@ -3333,25 +3102,6 @@ "which": "^1.2.9" } }, - "crypto-browserify": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", - "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", - "dev": true, - "requires": { - "browserify-cipher": "^1.0.0", - "browserify-sign": "^4.0.0", - "create-ecdh": "^4.0.0", - "create-hash": "^1.1.0", - "create-hmac": "^1.1.0", - "diffie-hellman": "^5.0.0", - "inherits": "^2.0.1", - "pbkdf2": "^3.0.3", - "public-encrypt": "^4.0.0", - "randombytes": "^2.0.0", - "randomfill": "^1.0.3" - } - }, "cssom": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.4.tgz", @@ -3450,15 +3200,6 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, - "deferred-leveldown": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-0.2.0.tgz", - "integrity": "sha1-LO8fER4cV4cNi7uK8mUOWHzS9bQ=", - "dev": true, - "requires": { - "abstract-leveldown": "~0.12.1" - } - }, "define-properties": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", @@ -3542,45 +3283,18 @@ "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", "dev": true }, - "des.js": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", - "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "detect-newline": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", "dev": true }, - "diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", - "dev": true - }, "diff-sequences": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-24.9.0.tgz", "integrity": "sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew==", "dev": true }, - "diffie-hellman": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", - "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "miller-rabin": "^4.0.0", - "randombytes": "^2.0.0" - } - }, "doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -3616,21 +3330,6 @@ "safer-buffer": "^2.1.0" } }, - "elliptic": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.1.tgz", - "integrity": "sha512-xvJINNLbTeWQjrl6X+7eQCrIy/YPv5XCpKW6kB5mKvtnGILoLDcySuwomfdzt0BMdLNVnuRNTuzKNHj0bva1Cg==", - "dev": true, - "requires": { - "bn.js": "^4.4.0", - "brorand": "^1.0.1", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.0" - } - }, "emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", @@ -3646,15 +3345,6 @@ "once": "^1.4.0" } }, - "errno": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", - "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", - "dev": true, - "requires": { - "prr": "~1.0.1" - } - }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3998,16 +3688,6 @@ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", "dev": true }, - "evp_bytestokey": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", - "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", - "dev": true, - "requires": { - "md5.js": "^1.3.4", - "safe-buffer": "^5.1.1" - } - }, "exec-sh": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.2.tgz", @@ -4332,12 +4012,6 @@ "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", "dev": true }, - "foreach": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.5.tgz", - "integrity": "sha1-C+4AUBiusmDQo6865ljdATbsG5k=", - "dev": true - }, "forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -4947,41 +4621,6 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, - "fwd-stream": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/fwd-stream/-/fwd-stream-1.0.4.tgz", - "integrity": "sha1-7Sgcq+1G/uz5Ie4y3ExQs3KsfPo=", - "dev": true, - "requires": { - "readable-stream": "~1.0.26-4" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - } - } - }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -5089,9 +4728,9 @@ } }, "handlebars": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.1.tgz", - "integrity": "sha512-C29UoFzHe9yM61lOsIlCE5/mQVGrnIOrOq7maQl76L7tYPCgC1og0Ajt6uWnX4ZTxBPnjw+CUvawphwCfJgUnA==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz", + "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==", "dev": true, "requires": { "neo-async": "^2.6.0", @@ -5214,37 +4853,6 @@ } } }, - "hash-base": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", - "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, - "hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", - "dev": true, - "requires": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "hosted-git-info": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.5.tgz", @@ -5280,12 +4888,6 @@ "safer-buffer": ">= 2.1.2 < 3" } }, - "idb-wrapper": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/idb-wrapper/-/idb-wrapper-1.7.2.tgz", - "integrity": "sha512-zfNREywMuf0NzDo9mVsL0yegjsirJxHpKHvWcyRozIqQy89g0a3U+oBPOCN4cc0oCiOuYgZHimzaW/R46G1Mpg==", - "dev": true - }, "ignore": { "version": "3.3.10", "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", @@ -5308,12 +4910,6 @@ "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", "dev": true }, - "indexof": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", - "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", - "dev": true - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5413,12 +5009,6 @@ "integrity": "sha1-LKmwM2UREYVUEvFr5dd8YqRYp2Y=", "dev": true }, - "is": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/is/-/is-0.2.7.tgz", - "integrity": "sha1-OzSixI81mXLzUEKEkZOucmS2NWI=", - "dev": true - }, "is-accessor-descriptor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", @@ -5532,12 +5122,6 @@ "kind-of": "^3.0.2" } }, - "is-object": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/is-object/-/is-object-0.1.2.tgz", - "integrity": "sha1-AO+8CIFsM8/ErIJR0TLhDcZQmNc=", - "dev": true - }, "is-path-cwd": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", @@ -5654,12 +5238,6 @@ "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true }, - "isbuffer": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/isbuffer/-/isbuffer-0.0.0.tgz", - "integrity": "sha1-OMFG2d9Si4v5sHAcPUPPEt8/w5s=", - "dev": true - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6805,12 +6383,6 @@ "verror": "1.10.0" } }, - "just-extend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", - "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", - "dev": true - }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -6826,224 +6398,33 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, - "left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", - "dev": true - }, - "level-blobs": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/level-blobs/-/level-blobs-0.1.7.tgz", - "integrity": "sha1-mrm5e7mfHtv594o0M+Ie1WOGva8=", + "launchdarkly-js-test-helpers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/launchdarkly-js-test-helpers/-/launchdarkly-js-test-helpers-1.1.0.tgz", + "integrity": "sha512-6kNqQ359DbKjefJV1z6YVgU/yHSQBWvNz0U6DfaS04Km8OTO6uH1bwVJbtseD4wI7aG/BDbdF7R/xkZE9wRfoA==", "dev": true, "requires": { - "level-peek": "1.0.6", - "once": "^1.3.0", - "readable-stream": "^1.0.26-4" + "@babel/core": "^7.6.4", + "@babel/preset-env": "^7.6.3", + "@babel/runtime": "^7.6.3", + "@types/node": "^12.12.11", + "selfsigned": "^1.10.4" }, "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "readable-stream": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", - "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "@types/node": { + "version": "12.12.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.14.tgz", + "integrity": "sha512-u/SJDyXwuihpwjXy7hOOghagLEV1KdAST6syfnOk6QZAMzZuWZqXy5aYYZbh8Jdpd4escVFP0MvftHNDb9pruA==", "dev": true } } }, - "level-filesystem": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/level-filesystem/-/level-filesystem-1.2.0.tgz", - "integrity": "sha1-oArKmRnEpN+v3KaoEI0iWq3/Y7M=", - "dev": true, - "requires": { - "concat-stream": "^1.4.4", - "errno": "^0.1.1", - "fwd-stream": "^1.0.4", - "level-blobs": "^0.1.7", - "level-peek": "^1.0.6", - "level-sublevel": "^5.2.0", - "octal": "^1.0.0", - "once": "^1.3.0", - "xtend": "^2.2.0" - } - }, - "level-fix-range": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-1.0.2.tgz", - "integrity": "sha1-vxW5Fa422EcMgh6IPd95zRZCCCg=", + "left-pad": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", + "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", "dev": true }, - "level-hooks": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/level-hooks/-/level-hooks-4.5.0.tgz", - "integrity": "sha1-G5rmGSKTDzMF0aYfxNg8gQLA3ZM=", - "dev": true, - "requires": { - "string-range": "~1.2" - } - }, - "level-js": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/level-js/-/level-js-2.2.4.tgz", - "integrity": "sha1-vAVfQYBjXUSJtWHJSG+jcOjBFpc=", - "dev": true, - "requires": { - "abstract-leveldown": "~0.12.0", - "idb-wrapper": "^1.5.0", - "isbuffer": "~0.0.0", - "ltgt": "^2.1.2", - "typedarray-to-buffer": "~1.0.0", - "xtend": "~2.1.2" - }, - "dependencies": { - "object-keys": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", - "integrity": "sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=", - "dev": true - }, - "xtend": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", - "integrity": "sha1-bv7MKk2tjmlixJAbM3znuoe10os=", - "dev": true, - "requires": { - "object-keys": "~0.4.0" - } - } - } - }, - "level-peek": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/level-peek/-/level-peek-1.0.6.tgz", - "integrity": "sha1-vsUccqgu5GTTNkNMfIdsP8vM538=", - "dev": true, - "requires": { - "level-fix-range": "~1.0.2" - } - }, - "level-sublevel": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/level-sublevel/-/level-sublevel-5.2.3.tgz", - "integrity": "sha1-dEwSxy0ucr543eO5tc2E1iGRQTo=", - "dev": true, - "requires": { - "level-fix-range": "2.0", - "level-hooks": ">=4.4.0 <5", - "string-range": "~1.2.1", - "xtend": "~2.0.4" - }, - "dependencies": { - "level-fix-range": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/level-fix-range/-/level-fix-range-2.0.0.tgz", - "integrity": "sha1-xBfWIVlEIVGhnZojZ4aPFyTC1Ug=", - "dev": true, - "requires": { - "clone": "~0.1.9" - } - }, - "object-keys": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.2.0.tgz", - "integrity": "sha1-zd7AKZiwkb5CvxA1rjLknxy26mc=", - "dev": true, - "requires": { - "foreach": "~2.0.1", - "indexof": "~0.0.1", - "is": "~0.2.6" - } - }, - "xtend": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.0.6.tgz", - "integrity": "sha1-XqZXptukRwacLlnFihE4ywxebO4=", - "dev": true, - "requires": { - "is-object": "~0.1.2", - "object-keys": "~0.2.0" - } - } - } - }, - "levelup": { - "version": "0.18.6", - "resolved": "https://registry.npmjs.org/levelup/-/levelup-0.18.6.tgz", - "integrity": "sha1-5qAcsIlhbI7MApHCqb0/DETj5es=", - "dev": true, - "requires": { - "bl": "~0.8.1", - "deferred-leveldown": "~0.2.0", - "errno": "~0.1.1", - "prr": "~0.0.0", - "readable-stream": "~1.0.26", - "semver": "~2.3.1", - "xtend": "~3.0.0" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - }, - "prr": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/prr/-/prr-0.0.0.tgz", - "integrity": "sha1-GoS4WQgyVQFBGFPQCB7j+obikmo=", - "dev": true - }, - "readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", - "dev": true, - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "semver": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-2.3.2.tgz", - "integrity": "sha1-uYSPJdbPNjMwc+ye+IVtQvEjPlI=", - "dev": true - }, - "string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", - "dev": true - }, - "xtend": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-3.0.0.tgz", - "integrity": "sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=", - "dev": true - } - } - }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7148,12 +6529,6 @@ } } }, - "lolex": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.1.0.tgz", - "integrity": "sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw==", - "dev": true - }, "loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -7173,12 +6548,6 @@ "yallist": "^2.1.2" } }, - "ltgt": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", - "integrity": "sha1-81ypHEk/e3PaDgdJUwTxezH4fuU=", - "dev": true - }, "magic-string": { "version": "0.25.4", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.4.tgz", @@ -7236,17 +6605,6 @@ "object-visit": "^1.0.0" } }, - "md5.js": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", - "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -7282,16 +6640,6 @@ } } }, - "miller-rabin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", - "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", - "dev": true, - "requires": { - "bn.js": "^4.0.0", - "brorand": "^1.0.1" - } - }, "mime-db": { "version": "1.36.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", @@ -7313,18 +6661,6 @@ "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", "dev": true }, - "minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true - }, - "minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", - "dev": true - }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -7446,26 +6782,11 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, - "nise": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.10.tgz", - "integrity": "sha512-sa0RRbj53dovjc7wombHmVli9ZihXbXCQ2uH3TNm03DyvOSIQbxg+pbqDKrk2oxMK1rtLGVlKxcB9rrc6X5YjA==", - "dev": true, - "requires": { - "@sinonjs/formatio": "^3.1.0", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "lolex": "^2.3.2", - "path-to-regexp": "^1.7.0" - }, - "dependencies": { - "lolex": { - "version": "2.7.5", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", - "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", - "dev": true - } - } + "node-forge": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.9.0.tgz", + "integrity": "sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ==", + "dev": true }, "node-int64": { "version": "0.4.0", @@ -7647,12 +6968,6 @@ } } }, - "octal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/octal/-/octal-1.0.0.tgz", - "integrity": "sha1-Y+cWKmjvvrniE1iNWOmJ0eXEUws=", - "dev": true - }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7765,20 +7080,6 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, - "parse-asn1": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.5.tgz", - "integrity": "sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ==", - "dev": true, - "requires": { - "asn1.js": "^4.0.0", - "browserify-aes": "^1.0.0", - "create-hash": "^1.1.0", - "evp_bytestokey": "^1.0.0", - "pbkdf2": "^3.0.3", - "safe-buffer": "^5.1.1" - } - }, "parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -7838,23 +7139,6 @@ "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", "dev": true }, - "path-to-regexp": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", - "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", - "dev": true, - "requires": { - "isarray": "0.0.1" - }, - "dependencies": { - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", - "dev": true - } - } - }, "path-type": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", @@ -7878,19 +7162,6 @@ "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", "dev": true }, - "pbkdf2": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.17.tgz", - "integrity": "sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==", - "dev": true, - "requires": { - "create-hash": "^1.1.2", - "create-hmac": "^1.1.4", - "ripemd160": "^2.0.1", - "safe-buffer": "^5.0.1", - "sha.js": "^2.4.8" - } - }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -8010,12 +7281,6 @@ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", "dev": true }, - "process-es6": { - "version": "0.11.6", - "resolved": "https://registry.npmjs.org/process-es6/-/process-es6-0.11.6.tgz", - "integrity": "sha1-xrs4n5qVH4K9TrFpYAEFvS/5x3g=", - "dev": true - }, "process-nextick-args": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", @@ -8038,12 +7303,6 @@ "sisteransi": "^1.0.3" } }, - "prr": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", - "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", - "dev": true - }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -8056,20 +7315,6 @@ "integrity": "sha512-AeUmQ0oLN02flVHXWh9sSJF7mcdFq0ppid/JkErufc3hGIV/AMa8Fo9VgDo/cT2jFdOWoFvHp90qqBH54W+gjQ==", "dev": true }, - "public-encrypt": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz", - "integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==", - "dev": true, - "requires": { - "bn.js": "^4.1.0", - "browserify-rsa": "^4.0.0", - "create-hash": "^1.1.0", - "parse-asn1": "^5.0.0", - "randombytes": "^2.0.1", - "safe-buffer": "^5.1.2" - } - }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -8092,25 +7337,6 @@ "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", "dev": true }, - "randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "requires": { - "safe-buffer": "^5.1.0" - } - }, - "randomfill": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", - "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", - "dev": true, - "requires": { - "randombytes": "^2.0.5", - "safe-buffer": "^5.1.0" - } - }, "react-is": { "version": "16.11.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", @@ -8176,7 +7402,8 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", - "dev": true + "dev": true, + "optional": true }, "braces": { "version": "2.3.2", @@ -8439,7 +7666,8 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", - "dev": true + "dev": true, + "optional": true }, "micromatch": { "version": "3.1.10", @@ -8676,16 +7904,6 @@ "glob": "^7.0.5" } }, - "ripemd160": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", - "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", - "dev": true, - "requires": { - "hash-base": "^3.0.0", - "inherits": "^2.0.1" - } - }, "rollup": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.26.0.tgz", @@ -8743,37 +7961,6 @@ "terser": "^4.1.3" } }, - "rollup-plugin-includepaths": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/rollup-plugin-includepaths/-/rollup-plugin-includepaths-0.2.3.tgz", - "integrity": "sha512-4QbSIZPDT+FL4SViEVCRi4cGCA64zQJu7u5qmCkO3ecHy+l9EQBsue15KfCpddfb6Br0q47V/v2+E2YUiqts9g==", - "dev": true - }, - "rollup-plugin-node-builtins": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-builtins/-/rollup-plugin-node-builtins-2.1.2.tgz", - "integrity": "sha1-JKH+1KQyV7a2Q3HYq8bOGrFFl+k=", - "dev": true, - "requires": { - "browserify-fs": "^1.0.0", - "buffer-es6": "^4.9.2", - "crypto-browserify": "^3.11.0", - "process-es6": "^0.11.2" - } - }, - "rollup-plugin-node-resolve": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-5.2.0.tgz", - "integrity": "sha512-jUlyaDXts7TW2CqQ4GaO5VJ4PwwaV8VUGA7+km3n6k6xtOEacf61u0VXwN80phY/evMcaS+9eIeJ9MOyDxt5Zw==", - "dev": true, - "requires": { - "@types/resolve": "0.0.8", - "builtin-modules": "^3.1.0", - "is-module": "^1.0.0", - "resolve": "^1.11.1", - "rollup-pluginutils": "^2.8.1" - } - }, "rollup-plugin-terser": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-5.1.2.tgz", @@ -8890,6 +8077,15 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true }, + "selfsigned": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-1.10.7.tgz", + "integrity": "sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA==", + "dev": true, + "requires": { + "node-forge": "0.9.0" + } + }, "semver": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz", @@ -8937,16 +8133,6 @@ } } }, - "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", - "dev": true, - "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -8974,32 +8160,6 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, - "sinon": { - "version": "7.2.7", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.2.7.tgz", - "integrity": "sha512-rlrre9F80pIQr3M36gOdoCEWzFAMDgHYD8+tocqOw+Zw9OZ8F84a80Ds69eZfcjnzDqqG88ulFld0oin/6rG/g==", - "dev": true, - "requires": { - "@sinonjs/commons": "^1.3.1", - "@sinonjs/formatio": "^3.2.1", - "@sinonjs/samsam": "^3.2.0", - "diff": "^3.5.0", - "lolex": "^3.1.0", - "nise": "^1.4.10", - "supports-color": "^5.5.0" - }, - "dependencies": { - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } - } - }, "sisteransi": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.3.tgz", @@ -9317,12 +8477,6 @@ } } }, - "string-range": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/string-range/-/string-range-1.2.2.tgz", - "integrity": "sha1-qJPtNH5yKZvIO++78qaSqNI51d0=", - "dev": true - }, "string-width": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", @@ -9651,12 +8805,6 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, - "typedarray-to-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-1.0.4.tgz", - "integrity": "sha1-m7i6DoQfs/TPH+fCRenz+opf6Zw=", - "dev": true - }, "typescript": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.0.1.tgz", @@ -10044,12 +9192,6 @@ "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, - "xtend": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", - "integrity": "sha1-7vax8ZjByN6vrYsXZaBNrUoBxak=", - "dev": true - }, "y18n": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", diff --git a/package.json b/package.json index 50b4380..3386f42 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@babel/plugin-transform-runtime": "7.6.2", "@babel/preset-env": "^7.6.3", "@babel/runtime": "7.6.3", + "@rollup/plugin-node-resolve": "^6.0.0", "@rollup/plugin-replace": "^2.2.0", "babel-eslint": "10.0.3", "babel-jest": "^24.9.0", @@ -55,6 +56,7 @@ "eslint-plugin-prettier": "2.6.0", "jest": "^24.9.0", "jsdom": "^11.11.0", + "launchdarkly-js-test-helpers": "1.1.0", "prettier": "1.11.1", "readline-sync": "1.4.9", "rimraf": "2.6.2", @@ -62,14 +64,10 @@ "rollup-plugin-babel": "^4.3.3", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-filesize": "^6.2.1", - "rollup-plugin-includepaths": "^0.2.3", - "rollup-plugin-node-builtins": "^2.1.2", - "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-terser": "^5.1.2", "rollup-plugin-uglify": "^6.0.3", "semver": "5.5.0", "semver-compare": "1.0.0", - "sinon": "7.2.7", "typescript": "3.0.1" }, "dependencies": { diff --git a/rollup.config.js b/rollup.config.js index a47585b..6ad9bcc 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,11 +1,10 @@ const pkg = require('./package.json'); -const resolve = require('rollup-plugin-node-resolve'); +const resolve = require('@rollup/plugin-node-resolve'); const commonjs = require('rollup-plugin-commonjs'); const babel = require('rollup-plugin-babel'); const replace = require('@rollup/plugin-replace'); const { terser } = require('rollup-plugin-terser'); const { uglify } = require('rollup-plugin-uglify'); -const builtins = require('rollup-plugin-node-builtins'); const filesize = require('rollup-plugin-filesize'); const env = process.env.NODE_ENV || 'development'; @@ -18,7 +17,6 @@ const basePlugins = [ 'process.env.NODE_ENV': JSON.stringify(env), VERSION: JSON.stringify(version), }), - builtins(), resolve({ mainFields: ['browser', 'module', 'main'], preferBuiltins: true, diff --git a/src/EventProcessor.js b/src/EventProcessor.js index 43e28b7..73d1a3b 100644 --- a/src/EventProcessor.js +++ b/src/EventProcessor.js @@ -12,11 +12,13 @@ export default function EventProcessor(platform, options, environmentId, emitter const userFilter = UserFilter(options); const inlineUsers = options.inlineUsersInEvents; const samplingInterval = options.samplingInterval; + const eventCapacity = options.eventCapacity; const flushInterval = options.flushInterval; const logger = options.logger; let queue = []; let lastKnownPastTime = 0; let disabled = false; + let exceededCapacity = false; let flushTimer; function shouldSampleEvent() { @@ -51,6 +53,18 @@ export default function EventProcessor(platform, options, environmentId, emitter return ret; } + function addToOutbox(event) { + if (queue.length < eventCapacity) { + queue.push(event); + exceededCapacity = false; + } else { + if (!exceededCapacity) { + exceededCapacity = true; + logger.warn(messages.eventCapacityExceeded()); + } + } + } + processor.enqueue = function(event) { if (disabled) { return; @@ -73,14 +87,14 @@ export default function EventProcessor(platform, options, environmentId, emitter } if (addFullEvent) { - queue.push(makeOutputEvent(event)); + addToOutbox(makeOutputEvent(event)); } if (addDebugEvent) { const debugEvent = utils.extend({}, event, { kind: 'debug' }); delete debugEvent['trackEvents']; delete debugEvent['debugEventsUntilDate']; delete debugEvent['variation']; - queue.push(debugEvent); + addToOutbox(debugEvent); } }; diff --git a/src/__tests__/EventProcessor-test.js b/src/__tests__/EventProcessor-test.js index b613340..2365764 100644 --- a/src/__tests__/EventProcessor-test.js +++ b/src/__tests__/EventProcessor-test.js @@ -1,11 +1,13 @@ -import sinon from 'sinon'; - import EventProcessor from '../EventProcessor'; +import * as messages from '../messages'; + import * as stubPlatform from './stubPlatform'; +// These tests verify that the event processor produces the expected event payload data for +// various inputs. The actual delivery of data is done by EventSender, which has its own +// tests; here, we use a mock EventSender. + describe('EventProcessor', () => { - let sandbox; - const mockEventSender = {}; const user = { key: 'userKey', name: 'Red' }; const filteredUser = { key: 'userKey', privateAttrs: ['name'] }; const eventsUrl = '/fake-url'; @@ -13,29 +15,42 @@ describe('EventProcessor', () => { const logger = stubPlatform.logger(); const defaultConfig = { eventsUrl: eventsUrl, + eventCapacity: 100, flushInterval: 2000, samplingInterval: 0, logger: logger, }; const platform = stubPlatform.defaults(); - mockEventSender.sendEvents = function(events, sync) { - mockEventSender.calls.push({ - events: events, - sync: !!sync, - }); - return Promise.resolve({ serverTime: mockEventSender.serverTime, status: mockEventSender.status || 200 }); - }; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - mockEventSender.calls = []; - mockEventSender.serverTime = null; - }); + function createMockEventSender() { + const calls = []; + let serverTime = null; + let status = 200; + const sender = { + calls, + sendEvents: (events, sync) => { + calls.push({ events: events, sync: !!sync }); + return Promise.resolve({ serverTime, status }); + }, + setServerTime: time => { + serverTime = time; + }, + setStatus: respStatus => { + status = respStatus; + }, + }; + return sender; + } - afterEach(() => { - sandbox.restore(); - }); + async function withProcessorAndSender(config, asyncCallback) { + const sender = createMockEventSender(); + const ep = EventProcessor(platform, config, envId, null, sender); + try { + return await asyncCallback(ep, sender); + } finally { + ep.stop(); + } + } function checkFeatureEvent(e, source, debug, inlineUser) { expect(e.kind).toEqual(debug ? 'debug' : 'feature'); @@ -70,362 +85,397 @@ describe('EventProcessor', () => { } it('should enqueue identify event', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; - ep.enqueue(event); - await ep.flush(); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; + ep.enqueue(event); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].events).toEqual([event]); + expect(mockEventSender.calls.length).toEqual(1); + expect(mockEventSender.calls[0].events).toEqual([event]); + }); }); it('filters user in identify event', async () => { const config = { ...defaultConfig, allAttributesPrivate: true }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - expect(mockEventSender.calls[0].events).toEqual([ - { - kind: 'identify', - creationDate: event.creationDate, - key: user.key, - user: filteredUser, - }, - ]); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const event = { kind: 'identify', creationDate: 1000, key: user.key, user: user }; + ep.enqueue(event); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + expect(mockEventSender.calls[0].events).toEqual([ + { + kind: 'identify', + creationDate: event.creationDate, + key: user.key, + user: filteredUser, + }, + ]); + }); }); it('queues individual feature event', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false); - checkSummaryEvent(output[1]); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const event = { + kind: 'feature', + creationDate: 1000, + key: 'flagkey', + user: user, + trackEvents: true, + }; + ep.enqueue(event); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false); + checkSummaryEvent(output[1]); + }); }); it('can include inline user in feature event', async () => { const config = { ...defaultConfig, inlineUsersInEvents: true }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const event = { + kind: 'feature', + creationDate: 1000, + key: 'flagkey', + user: user, + trackEvents: true, + }; + ep.enqueue(event); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, user); + checkSummaryEvent(output[1]); + }); }); it('can include reason in feature event', async () => { const config = { ...defaultConfig, inlineUsersInEvents: true }; const reason = { kind: 'FALLTHROUGH' }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - reason: reason, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, user); - checkSummaryEvent(output[1]); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const event = { + kind: 'feature', + creationDate: 1000, + key: 'flagkey', + user: user, + trackEvents: true, + reason: reason, + }; + ep.enqueue(event); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, user); + checkSummaryEvent(output[1]); + }); }); it('filters user in feature event', async () => { const config = { ...defaultConfig, allAttributesPrivate: true, inlineUsersInEvents: true }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const event = { - kind: 'feature', - creationDate: 1000, - key: 'flagkey', - user: user, - trackEvents: true, - }; - ep.enqueue(event); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], event, false, filteredUser); - checkSummaryEvent(output[1]); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const event = { + kind: 'feature', + creationDate: 1000, + key: 'flagkey', + user: user, + trackEvents: true, + }; + ep.enqueue(event); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], event, false, filteredUser); + checkSummaryEvent(output[1]); + }); }); it('sets event kind to debug if event is temporarily in debug mode', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const futureTime = new Date().getTime() + 1000000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: false, - debugEventsUntilDate: futureTime, - }; - ep.enqueue(e); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(2); - checkFeatureEvent(output[0], e, true, user); - checkSummaryEvent(output[1]); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const futureTime = new Date().getTime() + 1000000; + const e = { + kind: 'feature', + creationDate: 1000, + user: user, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: false, + debugEventsUntilDate: futureTime, + }; + ep.enqueue(e); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(2); + checkFeatureEvent(output[0], e, true, user); + checkSummaryEvent(output[1]); + }); }); it('can both track and debug an event', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const futureTime = new Date().getTime() + 1000000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: true, - debugEventsUntilDate: futureTime, - }; - ep.enqueue(e); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(3); - checkFeatureEvent(output[0], e, false); - checkFeatureEvent(output[1], e, true, user); - checkSummaryEvent(output[2]); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const futureTime = new Date().getTime() + 1000000; + const e = { + kind: 'feature', + creationDate: 1000, + user: user, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: true, + debugEventsUntilDate: futureTime, + }; + ep.enqueue(e); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(3); + checkFeatureEvent(output[0], e, false); + checkFeatureEvent(output[1], e, true, user); + checkSummaryEvent(output[2]); + }); }); it('expires debug mode based on client time if client time is later than server time', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - - // Pick a server time that is somewhat behind the client time - const serverTime = new Date().getTime() - 20000; - mockEventSender.serverTime = serverTime; - - // Send and flush an event we don't care about, just to set the last server time - ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); - await ep.flush(); - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the server time, but in the past compared to the client. - const debugUntil = serverTime + 1000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: false, - debugEventsUntilDate: debugUntil, - }; - ep.enqueue(e); - - // Should get a summary event only, not a full feature event - await ep.flush(); - expect(mockEventSender.calls.length).toEqual(2); - const output = mockEventSender.calls[1].events; - expect(output.length).toEqual(1); - checkSummaryEvent(output[0]); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + // Pick a server time that is somewhat behind the client time + const serverTime = new Date().getTime() - 20000; + mockEventSender.setServerTime(serverTime); + + // Send and flush an event we don't care about, just to set the last server time + ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); + await ep.flush(); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the server time, but in the past compared to the client. + const debugUntil = serverTime + 1000; + const e = { + kind: 'feature', + creationDate: 1000, + user: user, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', + trackEvents: false, + debugEventsUntilDate: debugUntil, + }; + ep.enqueue(e); + + // Should get a summary event only, not a full feature event + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(2); + const output = mockEventSender.calls[1].events; + expect(output.length).toEqual(1); + checkSummaryEvent(output[0]); + }); }); it('expires debug mode based on server time if server time is later than client time', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - - // Pick a server time that is somewhat ahead of the client time - const serverTime = new Date().getTime() + 20000; - mockEventSender.serverTime = serverTime; - - // Send and flush an event we don't care about, just to set the last server time - ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); - await ep.flush(); - - // Now send an event with debug mode on, with a "debug until" time that is further in - // the future than the client time, but in the past compared to the server. - const debugUntil = serverTime - 1000; - const e = { - kind: 'feature', - creationDate: 1000, - user: user, - key: 'flagkey', - version: 11, - variation: 1, - value: 'value', - trackEvents: false, - debugEventsUntilDate: debugUntil, - }; - ep.enqueue(e); - - // Should get a summary event only, not a full feature event - await ep.flush(); - expect(mockEventSender.calls.length).toEqual(2); - const output = mockEventSender.calls[1].events; - expect(output.length).toEqual(1); - checkSummaryEvent(output[0]); - }); - - it('summarizes nontracked events', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - function makeEvent(key, date, version, variation, value, defaultVal) { - return { + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + // Pick a server time that is somewhat ahead of the client time + const serverTime = new Date().getTime() + 20000; + mockEventSender.setServerTime(serverTime); + + // Send and flush an event we don't care about, just to set the last server time + ep.enqueue({ kind: 'identify', user: { key: 'otherUser' } }); + await ep.flush(); + + // Now send an event with debug mode on, with a "debug until" time that is further in + // the future than the client time, but in the past compared to the server. + const debugUntil = serverTime - 1000; + const e = { kind: 'feature', - creationDate: date, + creationDate: 1000, user: user, - key: key, - version: version, - variation: variation, - value: value, - default: defaultVal, + key: 'flagkey', + version: 11, + variation: 1, + value: 'value', trackEvents: false, + debugEventsUntilDate: debugUntil, }; - } - const e1 = makeEvent('flagkey1', 1000, 11, 1, 'value1', 'default1'); - const e2 = makeEvent('flagkey2', 2000, 22, 1, 'value2', 'default2'); - ep.enqueue(e1); - ep.enqueue(e2); - await ep.flush(); - - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - const se = output[0]; - checkSummaryEvent(se); - expect(se.startDate).toEqual(1000); - expect(se.endDate).toEqual(2000); - expect(se.features).toEqual({ - flagkey1: { - default: 'default1', - counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }], - }, - flagkey2: { - default: 'default2', - counters: [{ version: 22, variation: 1, value: 'value2', count: 1 }], - }, + ep.enqueue(e); + + // Should get a summary event only, not a full feature event + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(2); + const output = mockEventSender.calls[1].events; + expect(output.length).toEqual(1); + checkSummaryEvent(output[0]); + }); + }); + + it('summarizes nontracked events', async () => { + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + function makeEvent(key, date, version, variation, value, defaultVal) { + return { + kind: 'feature', + creationDate: date, + user: user, + key: key, + version: version, + variation: variation, + value: value, + default: defaultVal, + trackEvents: false, + }; + } + const e1 = makeEvent('flagkey1', 1000, 11, 1, 'value1', 'default1'); + const e2 = makeEvent('flagkey2', 2000, 22, 1, 'value2', 'default2'); + ep.enqueue(e1); + ep.enqueue(e2); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + const se = output[0]; + checkSummaryEvent(se); + expect(se.startDate).toEqual(1000); + expect(se.endDate).toEqual(2000); + expect(se.features).toEqual({ + flagkey1: { + default: 'default1', + counters: [{ version: 11, variation: 1, value: 'value1', count: 1 }], + }, + flagkey2: { + default: 'default2', + counters: [{ version: 22, variation: 1, value: 'value2', count: 1 }], + }, + }); }); }); it('queues custom event', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const e = { - kind: 'custom', - creationDate: 1000, - user: user, - key: 'eventkey', - data: { thing: 'stuff' }, - metricValue: 1.5, - }; - ep.enqueue(e); - await ep.flush(); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const e = { + kind: 'custom', + creationDate: 1000, + user: user, + key: 'eventkey', + data: { thing: 'stuff' }, + metricValue: 1.5, + }; + ep.enqueue(e); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e); + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e); + }); }); it('can include inline user in custom event', async () => { const config = { ...defaultConfig, inlineUsersInEvents: true }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const e = { - kind: 'custom', - creationDate: 1000, - user: user, - key: 'eventkey', - data: { thing: 'stuff' }, - }; - ep.enqueue(e); - await ep.flush(); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const e = { + kind: 'custom', + creationDate: 1000, + user: user, + key: 'eventkey', + data: { thing: 'stuff' }, + }; + ep.enqueue(e); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, user); + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e, user); + }); }); it('filters user in custom event', async () => { const config = { ...defaultConfig, allAttributesPrivate: true, inlineUsersInEvents: true }; - const ep = EventProcessor(platform, config, envId, null, mockEventSender); - const e = { - kind: 'custom', - creationDate: 1000, - user: user, - key: 'eventkey', - data: { thing: 'stuff' }, - }; - ep.enqueue(e); - await ep.flush(); + await withProcessorAndSender(config, async (ep, mockEventSender) => { + const e = { + kind: 'custom', + creationDate: 1000, + user: user, + key: 'eventkey', + data: { thing: 'stuff' }, + }; + ep.enqueue(e); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e, filteredUser); + }); + }); - expect(mockEventSender.calls.length).toEqual(1); - const output = mockEventSender.calls[0].events; - expect(output.length).toEqual(1); - checkCustomEvent(output[0], e, filteredUser); + it('enforces event capacity', async () => { + const config = { ...defaultConfig, eventCapacity: 1, logger: stubPlatform.logger() }; + const e0 = { kind: 'custom', creationDate: 1000, user: user, key: 'key0' }; + const e1 = { kind: 'custom', creationDate: 1001, user: user, key: 'key1' }; + const e2 = { kind: 'custom', creationDate: 1002, user: user, key: 'key2' }; + await withProcessorAndSender(config, async (ep, mockEventSender) => { + ep.enqueue(e0); + ep.enqueue(e1); + ep.enqueue(e2); + await ep.flush(); + + expect(mockEventSender.calls.length).toEqual(1); + const output = mockEventSender.calls[0].events; + expect(output.length).toEqual(1); + checkCustomEvent(output[0], e0); + + expect(config.logger.output.warn).toEqual([messages.eventCapacityExceeded()]); // warning is not repeated for e2 + }); }); it('sends nothing if there are no events to flush', async () => { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - await ep.flush(); - expect(mockEventSender.calls.length).toEqual(0); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + await ep.flush(); + expect(mockEventSender.calls.length).toEqual(0); + }); }); async function verifyUnrecoverableHttpError(status) { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const e = { kind: 'identify', creationDate: 1000, user: user }; - ep.enqueue(e); - mockEventSender.status = status; - await ep.flush(); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const e = { kind: 'identify', creationDate: 1000, user: user }; + ep.enqueue(e); + mockEventSender.setStatus(status); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); - ep.enqueue(e); - await ep.flush(); + expect(mockEventSender.calls.length).toEqual(1); + ep.enqueue(e); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); // still the one from our first flush + expect(mockEventSender.calls.length).toEqual(1); // still the one from our first flush + }); } async function verifyRecoverableHttpError(status) { - const ep = EventProcessor(platform, defaultConfig, envId, null, mockEventSender); - const e = { kind: 'identify', creationDate: 1000, user: user }; - ep.enqueue(e); - mockEventSender.status = status; - await ep.flush(); + await withProcessorAndSender(defaultConfig, async (ep, mockEventSender) => { + const e = { kind: 'identify', creationDate: 1000, user: user }; + ep.enqueue(e); + mockEventSender.setStatus(status); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(1); - ep.enqueue(e); - await ep.flush(); + expect(mockEventSender.calls.length).toEqual(1); + ep.enqueue(e); + await ep.flush(); - expect(mockEventSender.calls.length).toEqual(2); + expect(mockEventSender.calls.length).toEqual(2); + }); } it('stops sending events after a 401 error', () => verifyUnrecoverableHttpError(401)); diff --git a/src/__tests__/EventSender-test.js b/src/__tests__/EventSender-test.js index 3f91933..1a9aec2 100644 --- a/src/__tests__/EventSender-test.js +++ b/src/__tests__/EventSender-test.js @@ -1,29 +1,27 @@ +import EventSender from '../EventSender'; +import * as utils from '../utils'; + import * as base64 from 'base64-js'; +import { respond, networkError } from './mockHttp'; import * as stubPlatform from './stubPlatform'; -import { errorResponse, makeDefaultServer } from './testUtils'; -import EventSender from '../EventSender'; -import * as utils from '../utils'; + +// These tests verify that EventSender executes the expected HTTP requests to deliver events. Since +// the js-sdk-common package uses an abstraction of HTTP requests, these tests do not use HTTP but +// rather use a test implementation of our HTTP abstraction; the individual platform-specific SDKs +// are responsible for verifying that their own implementations of the same HTTP abstraction work +// correctly with real networking. describe('EventSender', () => { - const platform = stubPlatform.defaults(); - const platformWithoutCors = { ...platform, httpAllowsPost: () => false }; - let server; - const eventsUrl = '/fake-url'; + let platform; + let platformWithoutCors; const envId = 'env'; beforeEach(() => { - server = makeDefaultServer(); - }); - - afterEach(() => { - server.restore(); + platform = stubPlatform.defaults(); + platformWithoutCors = { ...platform, httpAllowsPost: () => false }; }); - function lastRequest() { - return server.requests[server.requests.length - 1]; - } - function fakeImageCreator() { const ret = function(url) { ret.urls.push(url); @@ -43,8 +41,8 @@ describe('EventSender', () => { return decodeURIComponent(escape(decodedStr)); } - function decodeOutputFromUrl(url) { - const prefix = eventsUrl + '/a/' + envId + '.gif?d='; + function decodeOutputFromUrl(url, baseUrl) { + const prefix = baseUrl + '/a/' + envId + '.gif?d='; if (!url.startsWith(prefix)) { throw 'URL "' + url + '" did not have expected prefix "' + prefix + '"'; } @@ -53,8 +51,9 @@ describe('EventSender', () => { describe('using image endpoint when CORS is not available', () => { it('should encode events in a single chunk if they fit', async () => { + const server = platform.testing.http.newServer(); const imageCreator = fakeImageCreator(); - const sender = EventSender(platformWithoutCors, eventsUrl, envId, imageCreator); + const sender = EventSender(platformWithoutCors, server.url, envId, imageCreator); const event1 = { kind: 'identify', key: 'userKey1' }; const event2 = { kind: 'identify', key: 'userKey2' }; const events = [event1, event2]; @@ -63,12 +62,15 @@ describe('EventSender', () => { const urls = imageCreator.urls; expect(urls.length).toEqual(1); - expect(decodeOutputFromUrl(urls[0])).toEqual(events); + expect(decodeOutputFromUrl(urls[0], server.url)).toEqual(events); + + expect(server.requests.length()).toEqual(0); }); it('should send events in multiple chunks if necessary', async () => { + const server = platform.testing.http.newServer(); const imageCreator = fakeImageCreator(); - const sender = EventSender(platformWithoutCors, eventsUrl, envId, imageCreator); + const sender = EventSender(platformWithoutCors, server.url, envId, imageCreator); const events = []; for (let i = 0; i < 80; i++) { events.push({ kind: 'identify', key: 'thisIsALongUserKey' + i }); @@ -78,94 +80,117 @@ describe('EventSender', () => { const urls = imageCreator.urls; expect(urls.length).toEqual(3); - expect(decodeOutputFromUrl(urls[0])).toEqual(events.slice(0, 31)); - expect(decodeOutputFromUrl(urls[1])).toEqual(events.slice(31, 62)); - expect(decodeOutputFromUrl(urls[2])).toEqual(events.slice(62, 80)); + expect(decodeOutputFromUrl(urls[0], server.url)).toEqual(events.slice(0, 31)); + expect(decodeOutputFromUrl(urls[1], server.url)).toEqual(events.slice(31, 61)); + expect(decodeOutputFromUrl(urls[2], server.url)).toEqual(events.slice(61, 80)); + + expect(server.requests.length()).toEqual(0); }); }); describe('using POST when CORS is available', () => { it('should send all events in request body', async () => { - const sender = EventSender(platform, eventsUrl, envId); + const server = platform.testing.http.newServer(); + server.byDefault(respond(202)); + const sender = EventSender(platform, server.url, envId); const events = []; for (let i = 0; i < 80; i++) { events.push({ kind: 'identify', key: 'thisIsALongUserKey' + i }); } await sender.sendEvents(events, false); - const r = lastRequest(); - expect(r.url).toEqual(eventsUrl + '/events/bulk/' + envId); - expect(r.method).toEqual('POST'); - expect(JSON.parse(r.requestBody)).toEqual(events); + + const r = await server.nextRequest(); + expect(r.path).toEqual('/events/bulk/' + envId); + expect(r.method).toEqual('post'); + expect(JSON.parse(r.body)).toEqual(events); }); it('should send custom user-agent header', async () => { - const sender = EventSender(platform, eventsUrl, envId); + const server = platform.testing.http.newServer(); + server.byDefault(respond(202)); + const sender = EventSender(platform, server.url, envId); const event = { kind: 'identify', key: 'userKey' }; await sender.sendEvents([event], false); - expect(lastRequest().requestHeaders['X-LaunchDarkly-User-Agent']).toEqual(utils.getLDUserAgentString(platform)); + + const r = await server.nextRequest(); + expect(r.headers['x-launchdarkly-user-agent']).toEqual(utils.getLDUserAgentString(platform)); }); const retryableStatuses = [400, 408, 429, 500, 503]; for (const i in retryableStatuses) { const status = retryableStatuses[i]; it('should retry on error ' + status, async () => { + const server = platform.testing.http.newServer(); let n = 0; - server.respondWith(req => { + server.byDefault((req, res) => { n++; - req.respond(n >= 2 ? 200 : status); + respond(n >= 2 ? 200 : status)(req, res); }); - const sender = EventSender(platform, eventsUrl, envId); + const sender = EventSender(platform, server.url, envId); const event = { kind: 'false', key: 'userKey' }; await sender.sendEvents([event], false); - expect(server.requests.length).toEqual(2); - expect(JSON.parse(server.requests[1].requestBody)).toEqual([event]); + + expect(server.requests.length()).toEqual(2); + await server.nextRequest(); + const r1 = await server.nextRequest(); + expect(JSON.parse(r1.body)).toEqual([event]); }); } it('should not retry more than once', async () => { + const server = platform.testing.http.newServer(); let n = 0; - server.respondWith(req => { + server.byDefault((req, res) => { n++; - req.respond(n >= 3 ? 200 : 503); + respond(n >= 3 ? 200 : 503)(req, res); }); - const sender = EventSender(platform, eventsUrl, envId); + const sender = EventSender(platform, server.url, envId); const event = { kind: 'false', key: 'userKey' }; await sender.sendEvents([event], false); - expect(server.requests.length).toEqual(2); + + expect(server.requests.length()).toEqual(2); }); it('should not retry on error 401', async () => { - server.respondWith(errorResponse(401)); - const sender = EventSender(platform, eventsUrl, envId); + const server = platform.testing.http.newServer(); + server.byDefault(respond(401)); + const sender = EventSender(platform, server.url, envId); const event = { kind: 'false', key: 'userKey' }; await sender.sendEvents([event], false); - expect(server.requests.length).toEqual(1); + + expect(server.requests.length()).toEqual(1); }); it('should retry on I/O error', async () => { + const server = platform.testing.http.newServer(); let n = 0; - server.respondWith(req => { + server.byDefault((req, res) => { n++; if (n >= 2) { - req.respond(200); + respond(200)(req, res); } else { - req.error(); + networkError()(req, res); } }); - const sender = EventSender(platform, eventsUrl, envId); + const sender = EventSender(platform, server.url, envId); const event = { kind: 'false', key: 'userKey' }; await sender.sendEvents([event], false); - expect(server.requests.length).toEqual(2); - expect(JSON.parse(server.requests[1].requestBody)).toEqual([event]); + + expect(server.requests.length()).toEqual(2); + await server.nextRequest(); + const r1 = await server.nextRequest(); + expect(JSON.parse(r1.body)).toEqual([event]); }); }); describe('When HTTP requests are not available at all', () => { it('should silently discard events', async () => { - const sender = EventSender(stubPlatform.withoutHttp(), eventsUrl, envId); + const server = platform.testing.http.newServer(); + const sender = EventSender(stubPlatform.withoutHttp(), server.url, envId); const event = { kind: 'false', key: 'userKey' }; await sender.sendEvents([event], false); - expect(server.requests.length).toEqual(0); + + expect(server.requests.length()).toEqual(0); }); }); }); diff --git a/src/__tests__/EventSummarizer-test.js b/src/__tests__/EventSummarizer-test.js index 98d323f..be5e065 100644 --- a/src/__tests__/EventSummarizer-test.js +++ b/src/__tests__/EventSummarizer-test.js @@ -1,5 +1,7 @@ import EventSummarizer from '../EventSummarizer'; +// These tests cover only the logic for counting feature requests in summary data. + describe('EventSummarizer', () => { const user = { key: 'key1' }; diff --git a/src/__tests__/LDClient-events-test.js b/src/__tests__/LDClient-events-test.js index f8de691..026f938 100644 --- a/src/__tests__/LDClient-events-test.js +++ b/src/__tests__/LDClient-events-test.js @@ -1,114 +1,136 @@ +import * as messages from '../messages'; + +import { withCloseable } from 'launchdarkly-js-test-helpers'; + +import { respondJson } from './mockHttp'; import * as stubPlatform from './stubPlatform'; -import { jsonResponse, makeBootstrap, makeDefaultServer, numericUser, stringifiedNumericUser } from './testUtils'; +import { makeBootstrap, numericUser, stringifiedNumericUser } from './testUtils'; + +// These tests verify that the client generates the appropriate analytics events for various scenarios. +// We use a mock event processor component so that the events are not sent anywhere. +// +// We also use a mock HTTP service in a few tests-- not to simulate an event-recorder instance, since +// we're not testing event delivery here, but to simulate the polling service in cases where the test +// logic involves a flag request. In all other cases we just start the client with bootstrap data. -describe('LDClient', () => { +describe('LDClient events', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const user = { key: 'user' }; const fakeUrl = 'http://fake'; let platform; - let server; beforeEach(() => { - server = makeDefaultServer(); platform = stubPlatform.defaults(); platform.testing.setCurrentUrl(fakeUrl); }); - afterEach(() => { - server.restore(); - }); - - describe('event generation', () => { - function stubEventProcessor() { - const ep = { events: [] }; - ep.start = function() {}; - ep.flush = function() {}; - ep.stop = function() {}; - ep.enqueue = function(e) { - ep.events.push(e); - }; - return ep; - } - - function expectIdentifyEvent(e, user) { - expect(e.kind).toEqual('identify'); - expect(e.user).toEqual(user); - } - - function expectFeatureEvent(e, key, value, variation, version, defaultVal, trackEvents, debugEventsUntilDate) { - expect(e.kind).toEqual('feature'); - expect(e.key).toEqual(key); - expect(e.value).toEqual(value); - expect(e.variation).toEqual(variation); - expect(e.version).toEqual(version); - expect(e.default).toEqual(defaultVal); - expect(e.trackEvents).toEqual(trackEvents); - expect(e.debugEventsUntilDate).toEqual(debugEventsUntilDate); - } - - it('sends an identify event at startup', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + function stubEventProcessor() { + const ep = { events: [] }; + ep.start = function() {}; + ep.flush = function() {}; + ep.stop = function() {}; + ep.enqueue = function(e) { + ep.events.push(e); + }; + return ep; + } + + async function withServer(asyncCallback) { + const server = platform.testing.http.newServer(); + server.byDefault(respondJson({})); + return await withCloseable(server, asyncCallback); + } + + async function withClientAndEventProcessor(user, extraConfig, asyncCallback) { + const ep = stubEventProcessor(); + const config = Object.assign({ baseUrl: 'shouldnt-use-this', bootstrap: {}, eventProcessor: ep }, extraConfig); + const client = platform.testing.makeClient(envName, user, config); + return await withCloseable(client, async () => await asyncCallback(client, ep)); + } + + function expectIdentifyEvent(e, user) { + expect(e.kind).toEqual('identify'); + expect(e.user).toEqual(user); + } + + function expectFeatureEvent(e, key, value, variation, version, defaultVal, trackEvents, debugEventsUntilDate) { + expect(e.kind).toEqual('feature'); + expect(e.key).toEqual(key); + expect(e.value).toEqual(value); + expect(e.variation).toEqual(variation); + expect(e.version).toEqual(version); + expect(e.default).toEqual(defaultVal); + expect(e.trackEvents).toEqual(trackEvents); + expect(e.debugEventsUntilDate).toEqual(debugEventsUntilDate); + } + + it('sends an identify event at startup', async () => { + await withClientAndEventProcessor(user, {}, async (client, ep) => { await client.waitForInitialization(); expect(ep.events.length).toEqual(1); expectIdentifyEvent(ep.events[0], user); }); + }); - it('stringifies user attributes in the identify event at startup', async () => { - // This just verifies that the event is being sent with the sanitized user, not the user that was passed in - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, numericUser, { eventProcessor: ep }); + it('stringifies user attributes in the identify event at startup', async () => { + // This just verifies that the event is being sent with the sanitized user, not the user that was passed in + await withClientAndEventProcessor(numericUser, {}, async (client, ep) => { await client.waitForInitialization(); expect(ep.events.length).toEqual(1); expectIdentifyEvent(ep.events[0], stringifiedNumericUser); }); + }); - it('sends an identify event when identify() is called', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - const user1 = { key: 'user1' }; - await client.waitForInitialization(); + it('sends an identify event when identify() is called', async () => { + // need a server because it'll do a polling request when we call identify + await withServer(async server => { + await withClientAndEventProcessor(user, { baseUrl: server.url }, async (client, ep) => { + const user1 = { key: 'user1' }; + await client.waitForInitialization(); - expect(ep.events.length).toEqual(1); - await client.identify(user1); - server.respond(); + expect(ep.events.length).toEqual(1); + await client.identify(user1); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[1], user1); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[1], user1); + }); }); + }); - it('stringifies user attributes in the identify event when identify() is called', async () => { - // This just verifies that the event is being sent with the sanitized user, not the user that was passed in - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - await client.waitForInitialization(); + it('stringifies user attributes in the identify event when identify() is called', async () => { + // This just verifies that the event is being sent with the sanitized user, not the user that was passed in + await withServer(async server => { + await withClientAndEventProcessor(user, { baseUrl: server.url }, async (client, ep) => { + await client.waitForInitialization(); - expect(ep.events.length).toEqual(1); - await client.identify(numericUser); + expect(ep.events.length).toEqual(1); + await client.identify(numericUser); - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[1], stringifiedNumericUser); + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[1], stringifiedNumericUser); + }); }); + }); - it('does not send an identify event if doNotTrack is set', async () => { - platform.testing.setDoNotTrack(true); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - const user1 = { key: 'user1' }; + it('does not send an identify event if doNotTrack is set', async () => { + platform.testing.setDoNotTrack(true); + await withServer(async server => { + await withClientAndEventProcessor(user, { baseUrl: server.url }, async (client, ep) => { + const user1 = { key: 'user1' }; - await client.waitForInitialization(); - await client.identify(user1); + await client.waitForInitialization(); + await client.identify(user1); - expect(ep.events.length).toEqual(0); + expect(ep.events.length).toEqual(0); + }); }); + }); - it('sends a feature event for variation()', async () => { - const initFlags = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - + it('sends a feature event for variation()', async () => { + const initData = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.variation('foo', 'x'); @@ -117,14 +139,13 @@ describe('LDClient', () => { expectIdentifyEvent(ep.events[0], user); expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); }); + }); - it('sends a feature event with reason for variationDetail()', async () => { - const initFlags = makeBootstrap({ - foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' } }, - }); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - + it('sends a feature event with reason for variationDetail()', async () => { + const initData = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' } }, + }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.variationDetail('foo', 'x'); @@ -133,14 +154,13 @@ describe('LDClient', () => { expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); expect(ep.events[1].reason).toEqual({ kind: 'OFF' }); }); + }); - it('does not include reason in event for variation() even if reason is available', async () => { - const initFlags = makeBootstrap({ - foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' } }, - }); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - + it('does not include reason in event for variation() even if reason is available', async () => { + const initData = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' } }, + }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.variation('foo', 'x'); @@ -149,14 +169,13 @@ describe('LDClient', () => { expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); expect(ep.events[1].reason).toBe(undefined); }); + }); - it('sends a feature event with reason for variation() if trackReason is set', async () => { - const initFlags = makeBootstrap({ - foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' }, trackReason: true }, - }); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - + it('sends a feature event with reason for variation() if trackReason is set', async () => { + const initData = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000, reason: { kind: 'OFF' }, trackReason: true }, + }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.variation('foo', 'x'); @@ -165,72 +184,72 @@ describe('LDClient', () => { expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2000, 'x'); expect(ep.events[1].reason).toEqual({ kind: 'OFF' }); }); + }); - it('sends a feature event on receiving a new flag value', async () => { - const ep = stubEventProcessor(); - const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; - const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - - server.respondWith(jsonResponse(oldFlags)); - - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - await client.waitForInitialization(); - - const user1 = { key: 'user1' }; - server.respondWith(jsonResponse(newFlags)); - await client.identify(user1); - - expect(ep.events.length).toEqual(3); - expectIdentifyEvent(ep.events[0], user); - expectIdentifyEvent(ep.events[1], user1); - expectFeatureEvent(ep.events[2], 'foo', 'b', 2, 2001); + it('sends a feature event on receiving a new flag value', async () => { + const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; + const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; + const initData = makeBootstrap(oldFlags); + await withServer(async server => { + server.byDefault(respondJson(newFlags)); + await withClientAndEventProcessor(user, { baseUrl: server.url, bootstrap: initData }, async (client, ep) => { + await client.waitForInitialization(); + + const user1 = { key: 'user1' }; + await client.identify(user1); + + expect(ep.events.length).toEqual(3); + expectIdentifyEvent(ep.events[0], user); + expectIdentifyEvent(ep.events[1], user1); + expectFeatureEvent(ep.events[2], 'foo', 'b', 2, 2001); + }); }); + }); - it('does not send a feature event for a new flag value if sendEventsOnlyForVariation is set', async () => { - const ep = stubEventProcessor(); - const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; - const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - - server.respondWith(jsonResponse(oldFlags)); - - const client = platform.testing.makeClient(envName, user, { - eventProcessor: ep, - sendEventsOnlyForVariation: true, + it('does not send a feature event for a new flag value if sendEventsOnlyForVariation is set', async () => { + const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; + const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; + const initData = makeBootstrap(oldFlags); + await withServer(async server => { + server.byDefault(respondJson(newFlags)); + const extraConfig = { sendEventsOnlyForVariation: true, baseUrl: server.url, bootstrap: initData }; + await withClientAndEventProcessor(user, extraConfig, async (client, ep) => { + await client.waitForInitialization(); + + const user1 = { key: 'user1' }; + await client.identify(user1); + + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectIdentifyEvent(ep.events[1], user1); }); - await client.waitForInitialization(); - - const user1 = { key: 'user1' }; - server.respondWith(jsonResponse(newFlags)); - await client.identify(user1); - - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectIdentifyEvent(ep.events[1], user1); }); + }); - it('does not send a feature event for a new flag value if there is a state provider', async () => { - const ep = stubEventProcessor(); - const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; - const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; - const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: oldFlags }); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, stateProvider: sp }); - - await client.waitForInitialization(); - - sp.emit('update', { flags: newFlags }); + it('does not send a feature event for a new flag value if there is a state provider', async () => { + const oldFlags = { foo: { value: 'a', variation: 1, version: 2, flagVersion: 2000 } }; + const newFlags = { foo: { value: 'b', variation: 2, version: 3, flagVersion: 2001 } }; + const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: oldFlags }); + await withServer(async server => { + server.byDefault(respondJson(newFlags)); + const extraConfig = { stateProvider: sp, baseUrl: server.url }; + await withClientAndEventProcessor(user, extraConfig, async (client, ep) => { + await client.waitForInitialization(); - expect(client.variation('foo')).toEqual('b'); - expect(ep.events.length).toEqual(1); - }); + sp.emit('update', { flags: newFlags }); - it('sends feature events for allFlags()', async () => { - const ep = stubEventProcessor(); - const initFlags = makeBootstrap({ - foo: { value: 'a', variation: 1, version: 2 }, - bar: { value: 'b', variation: 1, version: 3 }, + expect(client.variation('foo')).toEqual('b'); + expect(ep.events.length).toEqual(1); }); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); + }); + }); + it('sends feature events for allFlags()', async () => { + const initData = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2 }, + bar: { value: 'b', variation: 1, version: 3 }, + }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.allFlags(); @@ -239,31 +258,26 @@ describe('LDClient', () => { expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, null); expectFeatureEvent(ep.events[2], 'bar', 'b', 1, 3, null); }); + }); - it('does not send feature events for allFlags() if sendEventsOnlyForVariation is set', async () => { - const ep = stubEventProcessor(); - const initFlags = makeBootstrap({ - foo: { value: 'a', variation: 1, version: 2 }, - bar: { value: 'b', variation: 1, version: 3 }, - }); - const client = platform.testing.makeClient(envName, user, { - eventProcessor: ep, - bootstrap: initFlags, - sendEventsOnlyForVariation: true, - }); - + it('does not send feature events for allFlags() if sendEventsOnlyForVariation is set', async () => { + const initData = makeBootstrap({ + foo: { value: 'a', variation: 1, version: 2 }, + bar: { value: 'b', variation: 1, version: 3 }, + }); + const extraConfig = { sendEventsOnlyForVariation: true, bootstrap: initData }; + await withClientAndEventProcessor(user, extraConfig, async (client, ep) => { await client.waitForInitialization(); client.allFlags(); expect(ep.events.length).toEqual(1); expectIdentifyEvent(ep.events[0], user); }); + }); - it('uses "version" instead of "flagVersion" in event if "flagVersion" is absent', async () => { - const ep = stubEventProcessor(); - const initFlags = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2 } }); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - + it('uses "version" instead of "flagVersion" in event if "flagVersion" is absent', async () => { + const initData = makeBootstrap({ foo: { value: 'a', variation: 1, version: 2 } }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { await client.waitForInitialization(); client.variation('foo', 'x'); @@ -271,11 +285,10 @@ describe('LDClient', () => { expectIdentifyEvent(ep.events[0], user); expectFeatureEvent(ep.events[1], 'foo', 'a', 1, 2, 'x'); }); + }); - it('omits event version if flag does not exist', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); - + it('omits event version if flag does not exist', async () => { + await withClientAndEventProcessor(user, {}, async (client, ep) => { await client.waitForInitialization(); client.variation('foo', 'x'); @@ -283,31 +296,32 @@ describe('LDClient', () => { expectIdentifyEvent(ep.events[0], user); expectFeatureEvent(ep.events[1], 'foo', 'x', null, undefined, 'x'); }); + }); - it('can get metadata for events from bootstrap object', async () => { - const ep = stubEventProcessor(); - const initFlags = makeBootstrap({ - foo: { - value: 'bar', - variation: 1, - version: 2, - trackEvents: true, - debugEventsUntilDate: 1000, - }, + it('can get metadata for events from bootstrap object', async () => { + const initData = makeBootstrap({ + foo: { + value: 'bar', + variation: 1, + version: 2, + trackEvents: true, + debugEventsUntilDate: 1000, + }, + }); + await withClientAndEventProcessor(user, { bootstrap: initData }, async (client, ep) => { + await withCloseable(client, async () => { + await client.waitForInitialization(); + client.variation('foo', 'x'); + + expect(ep.events.length).toEqual(2); + expectIdentifyEvent(ep.events[0], user); + expectFeatureEvent(ep.events[1], 'foo', 'bar', 1, 2, 'x', true, 1000); }); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, bootstrap: initFlags }); - - await client.waitForInitialization(); - client.variation('foo', 'x'); - - expect(ep.events.length).toEqual(2); - expectIdentifyEvent(ep.events[0], user); - expectFeatureEvent(ep.events[1], 'foo', 'bar', 1, 2, 'x', true, 1000); }); + }); - it('sends an event for track()', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + it('sends an event for track()', async () => { + await withClientAndEventProcessor(user, {}, async (client, ep) => { await client.waitForInitialization(); client.track('eventkey'); @@ -320,10 +334,10 @@ describe('LDClient', () => { expect(trackEvent.data).toEqual(undefined); expect(trackEvent.url).toEqual(fakeUrl); }); + }); - it('sends an event for track() with data', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + it('sends an event for track() with data', async () => { + await withClientAndEventProcessor(user, {}, async (client, ep) => { const eventData = { thing: 'stuff' }; await client.waitForInitialization(); client.track('eventkey', eventData); @@ -337,10 +351,10 @@ describe('LDClient', () => { expect(trackEvent.data).toEqual(eventData); expect(trackEvent.url).toEqual(fakeUrl); }); + }); - it('sends an event for track() with metric value', async () => { - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + it('sends an event for track() with metric value', async () => { + await withClientAndEventProcessor(user, {}, async (client, ep) => { const eventData = { thing: 'stuff' }; const metricValue = 1.5; await client.waitForInitialization(); @@ -356,25 +370,53 @@ describe('LDClient', () => { expect(trackEvent.metricValue).toEqual(metricValue); expect(trackEvent.url).toEqual(fakeUrl); }); + }); - it('does not send an event for track() if doNotTrack is set', async () => { - platform.testing.setDoNotTrack(true); - const ep = stubEventProcessor(); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep }); + it('does not send an event for track() if doNotTrack is set', async () => { + platform.testing.setDoNotTrack(true); + await withClientAndEventProcessor(user, {}, async (client, ep) => { const eventData = { thing: 'stuff' }; await client.waitForInitialization(); client.track('eventkey', eventData); expect(ep.events.length).toEqual(0); }); + }); + + it('does not warn by default when tracking a custom event', async () => { + await withClientAndEventProcessor(user, {}, async client => { + await client.waitForInitialization(); - it('allows stateProvider to take over sending an event', async () => { - const ep = stubEventProcessor(); + client.track('known'); + expect(platform.testing.logger.output.warn).toEqual([]); + }); + }); + + it('emits an error when tracking a non-string custom event', async () => { + await withClientAndEventProcessor(user, {}, async client => { + await client.waitForInitialization(); + + const badCustomEventKeys = [123, [], {}, null, undefined]; + badCustomEventKeys.forEach(key => { + platform.testing.logger.reset(); + client.track(key); + expect(platform.testing.logger.output.error).toEqual([messages.unknownCustomEventKey(key)]); + }); + }); + }); + + it('should warn about missing user on first event', async () => { + await withClientAndEventProcessor(null, {}, async client => { + client.track('eventkey', null); + expect(platform.testing.logger.output.warn).toEqual([messages.eventWithoutUser()]); + }); + }); - const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: {} }); - const divertedEvents = []; - sp.enqueueEvent = event => divertedEvents.push(event); + it('allows stateProvider to take over sending an event', async () => { + const sp = stubPlatform.mockStateProvider({ environment: envName, user: user, flags: {} }); + const divertedEvents = []; + sp.enqueueEvent = event => divertedEvents.push(event); - const client = platform.testing.makeClient(envName, user, { eventProcessor: ep, stateProvider: sp }); + await withClientAndEventProcessor(user, { stateProvider: sp }, async (client, ep) => { await client.waitForInitialization(); client.track('eventkey'); diff --git a/src/__tests__/LDClient-localstorage-test.js b/src/__tests__/LDClient-localstorage-test.js index 4b0da64..d8b9ab4 100644 --- a/src/__tests__/LDClient-localstorage-test.js +++ b/src/__tests__/LDClient-localstorage-test.js @@ -1,139 +1,176 @@ -import * as stubPlatform from './stubPlatform'; -import { asyncSleep, errorResponse, jsonResponse, makeDefaultServer } from './testUtils'; import * as messages from '../messages'; import * as utils from '../utils'; +import { sleepAsync, withCloseable } from 'launchdarkly-js-test-helpers'; + +import { respond, respondJson } from './mockHttp'; +import * as stubPlatform from './stubPlatform'; + +// These tests cover the "bootstrap: 'localstorage'" mode. The actual implementation of local storage +// is provided by the platform-specific SDKs; we use a mock implementation here. + describe('LDClient local storage', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const user = { key: 'user' }; const lsKey = 'ld:' + envName + ':' + utils.btoa(JSON.stringify(user)); - let server; + let platform; beforeEach(() => { - server = makeDefaultServer(); + platform = stubPlatform.defaults(); }); - afterEach(() => { - server.restore(); - }); + async function withServer(asyncCallback) { + const server = platform.testing.http.newServer(); + server.byDefault(respondJson({})); + return await withCloseable(server, asyncCallback); + } + + async function withClient(user, extraConfig, asyncCallback) { + // We specify bootstrap: 'localstorage' for all tests in this file + const config = { baseUrl: 'shouldnt-use-this', bootstrap: 'localstorage', sendEvents: false, ...extraConfig }; + const client = platform.testing.makeClient(envName, user, config); + return await withCloseable(client, asyncCallback); + } describe('bootstrapping from local storage', () => { it('does not try to use local storage if the platform says it is unavailable', async () => { - const platform = stubPlatform.defaults(); platform.localStorage = null; - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - await client.waitForInitialization(); + await withServer(async server => { + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); - // should see a flag request to the server right away, as if bootstrap was not specified - expect(server.requests.length).toEqual(1); + // should see a flag request to the server right away, as if bootstrap was not specified + expect(server.requests.length()).toEqual(1); - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + }); + }); }); it('uses cached flags if available and requests flags from server after ready', async () => { - const platform = stubPlatform.defaults(); const json = '{"flag-key": 1}'; platform.testing.setLocalStorageImmediately(lsKey, json); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - await client.waitForInitialization(); + await withServer(async server => { + // This no-op request handler means that the flags request will simply hang with no + // response, so we can be sure that we're seeing only the initial flags from local storage. + server.byDefault(() => {}); - expect(client.variation('flag-key')).toEqual(1); - expect(server.requests.length).toEqual(1); - }); + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); - it('starts with empty flags and requests them from server if there are no cached flags', async () => { - const platform = stubPlatform.defaults(); - server.respondWith(jsonResponse({ 'flag-key': { value: 1 } })); + expect(client.variation('flag-key')).toEqual(1); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); + await sleepAsync(0); // allow any pending async tasks to complete - // don't wait for ready event - verifying that variation() doesn't throw an error if called before ready - expect(client.variation('flag-key', 0)).toEqual(0); + expect(server.requests.length()).toEqual(1); + }); + }); + }); - // verify that the flags get requested from LD - await client.waitForInitialization(); - expect(client.variation('flag-key')).toEqual(1); + it('starts with empty flags and requests them from server if there are no cached flags', async () => { + const flags = { 'flag-key': { value: 1 } }; + + await withServer(async server => { + server.byDefault(respondJson(flags)); + await withClient(user, { baseUrl: server.url }, async client => { + // don't wait for ready event - verifying that variation() doesn't throw an error if called before ready + expect(client.variation('flag-key', 0)).toEqual(0); + + // verify that the flags get requested from LD + await client.waitForInitialization(); + expect(client.variation('flag-key')).toEqual(1); + }); + }); }); it('should handle localStorage.get returning an error', async () => { - const platform = stubPlatform.defaults(); platform.localStorage.get = () => Promise.reject(new Error()); - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - await client.waitForInitialization(); - - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + const flags = { 'enable-foo': { value: true } }; + + await withServer(async server => { + server.byDefault(respondJson(flags)); + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + }); + }); }); it('should handle localStorage.set returning an error', async () => { - const platform = stubPlatform.defaults(); platform.localStorage.set = () => Promise.reject(new Error()); - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); + const flags = { 'enable-foo': { value: true } }; - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - await client.waitForInitialization(); + await withServer(async server => { + server.byDefault(respondJson(flags)); + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); - await asyncSleep(0); // allow any pending async tasks to complete + await sleepAsync(0); // allow any pending async tasks to complete - expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + expect(platform.testing.logger.output.warn).toEqual([messages.localStorageUnavailable()]); + }); + }); }); it('should not update cached settings if there was an error fetching flags', async () => { - const platform = stubPlatform.defaults(); const json = '{"enable-foo": true}'; - server.respondWith(errorResponse(503)); platform.testing.setLocalStorageImmediately(lsKey, json); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - await client.waitForInitialization(); + await withServer(async server => { + server.byDefault(respond(503)); + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); - await asyncSleep(0); // allow any pending async tasks to complete + await sleepAsync(0); // allow any pending async tasks to complete - const value = platform.testing.getLocalStorageImmediately(lsKey); - expect(value).toEqual(json); + const value = platform.testing.getLocalStorageImmediately(lsKey); + expect(value).toEqual(json); + }); + }); }); it('should use hash as localStorage key when secure mode is enabled', async () => { - const platform = stubPlatform.defaults(); - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - const lsKeyHash = 'ld:UNKNOWN_ENVIRONMENT_ID:totallyLegitHash'; - const client = platform.testing.makeClient(envName, user, { - bootstrap: 'localstorage', - hash: 'totallyLegitHash', - }); - - await client.waitForInitialization(); - const value = platform.testing.getLocalStorageImmediately(lsKeyHash); - expect(JSON.parse(value)).toEqual({ - $schema: 1, - 'enable-foo': { value: true }, + const hash = 'totallyLegitHash'; + const lsKeyHash = 'ld:UNKNOWN_ENVIRONMENT_ID:' + hash; + const flags = { 'enable-foo': { value: true } }; + + await withServer(async server => { + server.byDefault(respondJson(flags)); + await withClient(user, { baseUrl: server.url, hash }, async client => { + await client.waitForInitialization(); + const value = platform.testing.getLocalStorageImmediately(lsKeyHash); + expect(JSON.parse(value)).toEqual({ + $schema: 1, + 'enable-foo': { value: true }, + }); + }); }); }); it('should clear localStorage when user context is changed', async () => { - const platform = stubPlatform.defaults(); const lsKey2 = 'ld:UNKNOWN_ENVIRONMENT_ID:' + utils.btoa('{"key":"user2"}'); - + const flags = { 'enable-foo': { value: true } }; const user2 = { key: 'user2' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }); - - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - await client.waitForInitialization(); + await withServer(async server => { + server.byDefault(respondJson(flags)); + await withClient(user, { baseUrl: server.url }, async client => { + await client.waitForInitialization(); - await asyncSleep(0); // allow any pending async tasks to complete + await sleepAsync(0); // allow any pending async tasks to complete - await client.identify(user2); + await client.identify(user2); - const value1 = platform.testing.getLocalStorageImmediately(lsKey); - expect(value1).not.toEqual(expect.anything()); - const value2 = platform.testing.getLocalStorageImmediately(lsKey2); - expect(JSON.parse(value2)).toEqual({ - $schema: 1, - 'enable-foo': { value: true }, + const value1 = platform.testing.getLocalStorageImmediately(lsKey); + expect(value1).not.toEqual(expect.anything()); + const value2 = platform.testing.getLocalStorageImmediately(lsKey2); + expect(JSON.parse(value2)).toEqual({ + $schema: 1, + 'enable-foo': { value: true }, + }); + }); }); }); }); diff --git a/src/__tests__/LDClient-streaming-test.js b/src/__tests__/LDClient-streaming-test.js index 2cd71d8..278d846 100644 --- a/src/__tests__/LDClient-streaming-test.js +++ b/src/__tests__/LDClient-streaming-test.js @@ -1,562 +1,609 @@ -import EventSource, { sources } from './EventSource-mock'; - import * as utils from '../utils'; + +import { eventSink, sleepAsync, withCloseable } from 'launchdarkly-js-test-helpers'; + +import EventSource from './EventSource-mock'; +import { respondJson } from './mockHttp'; import * as stubPlatform from './stubPlatform'; -import { asyncSleep, jsonResponse, makeBootstrap, makeDefaultServer, promiseListener } from './testUtils'; +import { makeBootstrap } from './testUtils'; -describe('LDClient', () => { +// These tests verify the client's optional streaming behavior. The actual implementation of +// the SSE client is provided by the platform-specific SDKs (e.g. the browser SDK uses +// EventSource, other SDKs use the js-eventsource polyfill) so these tests use only a mock +// implementation, verifying that the SDK interacts properly with the stream abstraction. + +describe('LDClient streaming', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const lsKey = 'ld:UNKNOWN_ENVIRONMENT_ID:' + utils.btoa('{"key":"user"}'); const user = { key: 'user' }; const encodedUser = 'eyJrZXkiOiJ1c2VyIn0'; const hash = '012345789abcde'; let platform; - let server; beforeEach(() => { - Object.defineProperty(window, 'EventSource', { - value: EventSource, - writable: true, - }); - for (const key in sources) { - delete sources[key]; - } - - server = makeDefaultServer(); platform = stubPlatform.defaults(); }); - afterEach(() => { - server.restore(); - }); + async function withClientAndServer(extraConfig, asyncCallback) { + const server = platform.testing.http.newServer(); + server.byDefault(respondJson({})); + const config = { ...extraConfig, baseUrl: server.url }; + const client = platform.testing.makeClient(envName, user, config); + return await withCloseable(client, async () => await asyncCallback(client, server)); + } describe('streaming/event listening', () => { const streamUrl = 'https://clientstream.launchdarkly.com'; const fullStreamUrlWithUser = streamUrl + '/eval/' + envName + '/' + encodedUser; - function streamEvents() { - return sources[fullStreamUrlWithUser].__emitter._events; - } - - function expectStreamUrlIsOpen(url) { - expect(Object.keys(sources)).toEqual([url]); + async function expectStreamConnecting(url) { + const stream = await platform.testing.expectStream(url); + expect(stream.eventSource.readyState === EventSource.CONNECTING); + return stream; } function expectNoStreamIsOpen() { - expect(sources).toMatchObject({}); + expect(platform.testing.eventSourcesCreated.length()).toEqual(0); } it('does not connect to the stream by default', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); - expectNoStreamIsOpen(); + expectNoStreamIsOpen(); + }); }); it('connects to the stream if options.streaming is true', async () => { - const client = platform.testing.makeClient(envName, user, { streaming: true }); - await client.waitForInitialization(); + await withClientAndServer({ streaming: true }, async client => { + await client.waitForInitialization(); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + await platform.testing.expectStream(fullStreamUrlWithUser); + }); }); describe('setStreaming()', () => { it('can connect to the stream', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); - client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.setStreaming(true); + await expectStreamConnecting(fullStreamUrlWithUser); + }); }); it('can disconnect from the stream', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); - client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser); - client.setStreaming(false); - expectNoStreamIsOpen(); + client.setStreaming(true); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + client.setStreaming(false); + expect(stream.eventSource.readyState === EventSource.CLOSED); + }); }); }); describe('on("change")', () => { it('connects to the stream if not otherwise overridden', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.on('change', () => {}); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.on('change', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + await expectStreamConnecting(fullStreamUrlWithUser); + }); }); it('also connects if listening for a specific flag', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.on('change:flagkey', () => {}); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.on('change:flagkey', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + await expectStreamConnecting(fullStreamUrlWithUser); + }); }); it('does not connect if some other kind of event was specified', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.on('error', () => {}); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.on('error', () => {}); - expectNoStreamIsOpen(); + expectNoStreamIsOpen(); + }); }); it('does not connect if options.streaming is explicitly set to false', async () => { - const client = platform.testing.makeClient(envName, user, { streaming: false }); - await client.waitForInitialization(); - client.on('change', () => {}); + await withClientAndServer({ streaming: false }, async client => { + await client.waitForInitialization(); + client.on('change', () => {}); - expectNoStreamIsOpen(); + expectNoStreamIsOpen(); + }); }); it('does not connect if setStreaming(false) was called', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.setStreaming(false); - client.on('change', () => {}); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.setStreaming(false); + client.on('change', () => {}); - expectNoStreamIsOpen(); + expectNoStreamIsOpen(); + }); }); }); describe('off("change")', () => { it('disconnects from the stream if all event listeners are removed', async () => { - const client = platform.testing.makeClient(envName, user); - const listener1 = () => {}; - const listener2 = () => {}; - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + const listener1 = () => {}; + const listener2 = () => {}; + await client.waitForInitialization(); - client.on('change', listener1); - client.on('change:flagkey', listener2); - client.on('error', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.on('change', listener1); + client.on('change:flagKey', listener2); + client.on('error', () => {}); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); - client.off('change', listener1); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.off('change', listener1); + expect(stream.eventSource.readyState).toEqual(EventSource.CONNECTING); - client.off('change:flagkey', listener2); - expectNoStreamIsOpen(); + client.off('change:flagKey', listener2); + expect(stream.eventSource.readyState).toEqual(EventSource.CLOSED); + }); }); it('does not disconnect if setStreaming(true) was called, but still removes event listener', async () => { const changes1 = []; const changes2 = []; - const client = platform.testing.makeClient(envName, user); - const listener1 = allValues => changes1.push(allValues); - const listener2 = newValue => changes2.push(newValue); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + const listener1 = allValues => changes1.push(allValues); + const listener2 = newValue => changes2.push(newValue); + await client.waitForInitialization(); - client.setStreaming(true); + client.setStreaming(true); - client.on('change', listener1); - client.on('change:flag', listener2); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.on('change', listener1); + client.on('change:flagKey', listener2); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); - streamEvents().put({ - data: '{"flag":{"value":"a","version":1}}', - }); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":"a","version":1}}', + }); - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a']); + expect(changes1).toEqual([{ flagKey: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a']); - client.off('change', listener1); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.off('change', listener1); + expect(stream.eventSource.readyState).toEqual(EventSource.CONNECTING); - streamEvents().put({ - data: '{"flag":{"value":"b","version":1}}', - }); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":"b","version":1}}', + }); - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a', 'b']); + expect(changes1).toEqual([{ flagKey: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); - client.off('change:flag', listener2); - expectStreamUrlIsOpen(fullStreamUrlWithUser); + client.off('change:flagKey', listener2); + expect(stream.eventSource.readyState).toEqual(EventSource.CONNECTING); - streamEvents().put({ - data: '{"flag":{"value":"c","version":1}}', - }); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":"c","version":1}}', + }); - expect(changes1).toEqual([{ flag: { current: 'a', previous: undefined } }]); - expect(changes2).toEqual(['a', 'b']); + expect(changes1).toEqual([{ flagKey: { current: 'a', previous: undefined } }]); + expect(changes2).toEqual(['a', 'b']); + }); }); }); it('passes the secure mode hash in the stream URL if provided', async () => { - const client = platform.testing.makeClient(envName, user, { hash: hash }); - await client.waitForInitialization(); - client.on('change:flagkey', () => {}); + await withClientAndServer({ hash }, async client => { + await client.waitForInitialization(); + client.on('change:flagKey', () => {}); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash); + await expectStreamConnecting(fullStreamUrlWithUser + '?h=' + hash); + }); }); it('passes withReasons parameter if provided', async () => { - const client = platform.testing.makeClient(envName, user, { evaluationReasons: true }); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ evaluationReasons: true }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?withReasons=true'); + await expectStreamConnecting(fullStreamUrlWithUser + '?withReasons=true'); + }); }); it('passes secure mode hash and withReasons if provided', async () => { - const client = platform.testing.makeClient(envName, user, { hash: hash, evaluationReasons: true }); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ hash, evaluationReasons: true }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - expectStreamUrlIsOpen(fullStreamUrlWithUser + '?h=' + hash + '&withReasons=true'); + await expectStreamConnecting(fullStreamUrlWithUser + '?h=' + hash + '&withReasons=true'); + }); }); it('handles stream ping message by getting flags', async () => { - server.respondWith(jsonResponse({ 'enable-foo': { value: true, version: 1 } })); - - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({}, async (client, server) => { + server.byDefault(respondJson({ flagKey: { value: true, version: 1 } })); + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().ping(); - await asyncSleep(20); // give response handler a chance to execute + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('ping'); + await sleepAsync(20); // give response handler a chance to execute - expect(client.variation('enable-foo')).toEqual(true); + expect(client.variation('flagKey')).toEqual(true); + }); }); it('handles stream put message by updating flags', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":true,"version":1}}', + }); - expect(client.variation('enable-foo')).toEqual(true); + expect(client.variation('flagKey')).toEqual(true); + }); }); it('updates local storage for put message if using local storage', async () => { - const platform = stubPlatform.defaults(); - platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); + platform.testing.setLocalStorageImmediately(lsKey, '{"flagKey":false}'); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ bootstrap: 'localstorage' }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":true,"version":1}}', + }); - expect(client.variation('enable-foo')).toEqual(true); - const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); - expect(storageData).toMatchObject({ 'enable-foo': { value: true, version: 1 } }); + expect(client.variation('flagKey')).toEqual(true); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ flagKey: { value: true, version: 1 } }); + }); }); it('fires global change event when flags are updated from put event', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change', receivedChange.callback); + const receivedChange = eventSink(client, 'change'); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":true,"version":1}}', + }); - const changes = await receivedChange; - expect(changes).toEqual({ - 'enable-foo': { current: true, previous: false }, + const changes = await receivedChange.take(); + expect(changes).toEqual({ + flagKey: { current: true, previous: false }, + }); }); }); it('does not fire change event if new and old values are equivalent JSON objects', async () => { - const client = platform.testing.makeClient(envName, user, { + const config = { bootstrap: { 'will-change': 3, 'wont-change': { a: 1, b: 2 }, }, - }); - await client.waitForInitialization(); + }; + await withClientAndServer(config, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change', receivedChange.callback); + const receivedChange = eventSink(client, 'change'); - const putData = { - 'will-change': { value: 4, version: 2 }, - 'wont-change': { value: { b: 2, a: 1 }, version: 2 }, - }; - streamEvents().put({ data: JSON.stringify(putData) }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + const putData = { + 'will-change': { value: 4, version: 2 }, + 'wont-change': { value: { b: 2, a: 1 }, version: 2 }, + }; + stream.eventSource.mockEmit('put', { data: JSON.stringify(putData) }); - const changes = await receivedChange; - expect(changes).toEqual({ - 'will-change': { current: 4, previous: 3 }, + const changes = await receivedChange.take(); + expect(changes).toEqual({ + 'will-change': { current: 4, previous: 3 }, + }); }); }); it('fires individual change event when flags are updated from put event', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change:enable-foo', receivedChange.callback); + const receivedChange = eventSink(client, 'change:flagKey'); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":true,"version":1}}', + }); - const args = await receivedChange; - expect(args).toEqual([true, false]); + const args = await receivedChange.take(); + expect(args).toEqual([true, false]); + }); }); it('handles patch message by updating flag', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":true,"version":1}' }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { data: '{"key":"flagKey","value":true,"version":1}' }); - expect(client.variation('enable-foo')).toEqual(true); + expect(client.variation('flagKey')).toEqual(true); + }); }); it('does not update flag if patch version < flag version', async () => { - const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + const initData = makeBootstrap({ flagKey: { value: 'a', version: 2 } }); + await withClientAndServer({ bootstrap: initData }, async client => { + await client.waitForInitialization(); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); - client.setStreaming(true); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { data: '{"key":"flagKey","value":"b","version":1}' }); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); + }); }); it('does not update flag if patch version == flag version', async () => { - const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + const initData = makeBootstrap({ flagKey: { value: 'a', version: 2 } }); + await withClientAndServer({ bootstrap: initData }, async client => { + await client.waitForInitialization(); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); - client.setStreaming(true); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":2}' }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { data: '{"key":"flagKey","value":"b","version":2}' }); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); + }); }); it('updates flag if patch has a version and flag has no version', async () => { - const initData = makeBootstrap({ 'enable-foo': { value: 'a' } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + const initData = makeBootstrap({ flagKey: { value: 'a' } }); + await withClientAndServer({ bootstrap: initData }, async client => { + await client.waitForInitialization(); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); - client.setStreaming(true); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b","version":1}' }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { data: '{"key":"flagKey","value":"b","version":1}' }); - expect(client.variation('enable-foo')).toEqual('b'); + expect(client.variation('flagKey')).toEqual('b'); + }); }); it('updates flag if flag has a version and patch has no version', async () => { - const initData = makeBootstrap({ 'enable-foo': { value: 'a', version: 2 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + const initData = makeBootstrap({ flagKey: { value: 'a', version: 2 } }); + await withClientAndServer({ bootstrap: initData }, async client => { + await client.waitForInitialization(); - expect(client.variation('enable-foo')).toEqual('a'); + expect(client.variation('flagKey')).toEqual('a'); - client.setStreaming(true); + client.setStreaming(true); - streamEvents().patch({ data: '{"key":"enable-foo","value":"b"}' }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { data: '{"key":"flagKey","value":"b"}' }); - expect(client.variation('enable-foo')).toEqual('b'); + expect(client.variation('flagKey')).toEqual('b'); + }); }); it('updates local storage for patch message if using local storage', async () => { - const platform = stubPlatform.defaults(); - platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); + platform.testing.setLocalStorageImmediately(lsKey, '{"flagKey":false}'); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ bootstrap: 'localstorage' }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().put({ - data: '{"enable-foo":{"value":true,"version":1}}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('put', { + data: '{"flagKey":{"value":true,"version":1}}', + }); - expect(client.variation('enable-foo')).toEqual(true); - const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); - expect(storageData).toMatchObject({ 'enable-foo': { value: true, version: 1 } }); + expect(client.variation('flagKey')).toEqual(true); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ flagKey: { value: true, version: 1 } }); + }); }); it('fires global change event when flag is updated from patch event', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change', receivedChange.callback); + const receivedChange = eventSink(client, 'change'); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { + data: '{"key":"flagKey","value":true,"version":1}', + }); - const changes = await receivedChange; - expect(changes).toEqual({ - 'enable-foo': { current: true, previous: false }, + const changes = await receivedChange.take(); + expect(changes).toEqual({ + flagKey: { current: true, previous: false }, + }); }); }); it('fires individual change event when flag is updated from patch event', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change:enable-foo', receivedChange.callback); + const receivedChange = eventSink(client, 'change:flagKey'); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { + data: '{"key":"flagKey","value":true,"version":1}', + }); - const args = await receivedChange; - expect(args).toEqual([true, false]); + const args = await receivedChange.take(); + expect(args).toEqual([true, false]); + }); }); it('fires global change event when flag is newly created from patch event', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change', receivedChange.callback); + const receivedChange = eventSink(client, 'change'); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { + data: '{"key":"flagKey","value":true,"version":1}', + }); - const changes = await receivedChange; - expect(changes).toEqual({ - 'enable-foo': { current: true }, + const changes = await receivedChange.take(); + expect(changes).toEqual({ + flagKey: { current: true }, + }); }); }); it('fires individual change event when flag is newly created from patch event', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change:enable-foo', receivedChange.callback); + const receivedChange = eventSink(client, 'change:flagKey'); - streamEvents().patch({ - data: '{"key":"enable-foo","value":true,"version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('patch', { + data: '{"key":"flagKey","value":true,"version":1}', + }); - const args = await receivedChange; - expect(args).toEqual([true, undefined]); + const args = await receivedChange.take(); + expect(args).toEqual([true, undefined]); + }); }); it('handles delete message by deleting flag', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': false } }); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ bootstrap: { flagKey: false } }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"flagKey","version":1}', + }); - expect(client.variation('enable-foo')).toBeUndefined(); + expect(client.variation('flagKey')).toBeUndefined(); + }); }); it('handles delete message for unknown flag by storing placeholder', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().delete({ - data: '{"key":"mystery","version":3}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"mystery","version":3}', + }); - // The following patch message should be ignored because it has a lower version than the deleted placeholder - streamEvents().patch({ - data: '{"key":"mystery","value":"yes","version":2}', - }); + // The following patch message should be ignored because it has a lower version than the deleted placeholder + stream.eventSource.mockEmit('patch', { + data: '{"key":"mystery","value":"yes","version":2}', + }); - expect(client.variation('mystery')).toBeUndefined(); + expect(client.variation('mystery')).toBeUndefined(); + }); }); it('ignores delete message with lower version', async () => { - const initData = makeBootstrap({ flag: { value: 'yes', version: 3 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); - client.setStreaming(true); + const initData = makeBootstrap({ flagKey: { value: 'yes', version: 3 } }); + await withClientAndServer({ bootstrap: initData }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().delete({ - data: '{"key":"flag","version":2}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"flagKey","version":2}', + }); - expect(client.variation('flag')).toEqual('yes'); + expect(client.variation('flagKey')).toEqual('yes'); + }); }); it('fires global change event when flag is deleted', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': true } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: true } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change', receivedChange.callback); + const receivedChange = eventSink(client, 'change'); - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"flagKey","version":1}', + }); - const changes = await receivedChange; - expect(changes).toEqual({ - 'enable-foo': { previous: true }, + const changes = await receivedChange.take(); + expect(changes).toEqual({ + flagKey: { previous: true }, + }); }); }); it('fires individual change event when flag is deleted', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { 'enable-foo': true } }); - await client.waitForInitialization(); + await withClientAndServer({ bootstrap: { flagKey: true } }, async client => { + await client.waitForInitialization(); - const receivedChange = promiseListener(); - client.on('change:enable-foo', receivedChange.callback); + const receivedChange = eventSink(client, 'change:flagKey'); - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"flagKey","version":1}', + }); - const args = await receivedChange; - expect(args).toEqual([undefined, true]); + const args = await receivedChange.take(); + expect(args).toEqual([undefined, true]); + }); }); it('updates local storage for delete message if using local storage', async () => { - const platform = stubPlatform.defaults(); - platform.testing.setLocalStorageImmediately(lsKey, '{"enable-foo":false}'); + platform.testing.setLocalStorageImmediately(lsKey, '{"flagKey":false}'); - const client = platform.testing.makeClient(envName, user, { bootstrap: 'localstorage' }, platform); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({ bootstrap: 'localstorage' }, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - streamEvents().delete({ - data: '{"key":"enable-foo","version":1}', - }); + const stream = await expectStreamConnecting(fullStreamUrlWithUser); + stream.eventSource.mockEmit('delete', { + data: '{"key":"flagKey","version":1}', + }); - expect(client.variation('enable-foo')).toEqual(undefined); - const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); - expect(storageData).toMatchObject({ 'enable-foo': { version: 1, deleted: true } }); + expect(client.variation('flagKey')).toEqual(undefined); + const storageData = JSON.parse(platform.testing.getLocalStorageImmediately(lsKey)); + expect(storageData).toMatchObject({ flagKey: { version: 1, deleted: true } }); + }); }); it('reconnects to stream if the user changes', async () => { const user2 = { key: 'user2' }; const encodedUser2 = 'eyJrZXkiOiJ1c2VyMiJ9'; - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - client.setStreaming(true); + await withClientAndServer({}, async client => { + await client.waitForInitialization(); + client.setStreaming(true); - expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser]).toBeDefined(); + await expectStreamConnecting(streamUrl + '/eval/' + envName + '/' + encodedUser); - await client.identify(user2); - expect(sources[streamUrl + '/eval/' + envName + '/' + encodedUser2]).toBeDefined(); + await client.identify(user2); + await expectStreamConnecting(streamUrl + '/eval/' + envName + '/' + encodedUser2); + }); }); }); }); diff --git a/src/__tests__/LDClient-test.js b/src/__tests__/LDClient-test.js index b3d9ecc..13fe7b8 100644 --- a/src/__tests__/LDClient-test.js +++ b/src/__tests__/LDClient-test.js @@ -1,36 +1,37 @@ -import semverCompare from 'semver-compare'; - -import * as stubPlatform from './stubPlatform'; -import { - asyncify, - errorResponse, - jsonResponse, - makeBootstrap, - makeDefaultServer, - numericUser, - promiseListener, - stringifiedNumericUser, -} from './testUtils'; - import * as LDClient from '../index'; import * as errors from '../errors'; import * as messages from '../messages'; import * as utils from '../utils'; +import semverCompare from 'semver-compare'; +import { eventSink, promisifySingle, sleepAsync, withCloseable } from 'launchdarkly-js-test-helpers'; + +import { respond, respondJson } from './mockHttp'; +import * as stubPlatform from './stubPlatform'; +import { makeBootstrap, numericUser, stringifiedNumericUser } from './testUtils'; + describe('LDClient', () => { const envName = 'UNKNOWN_ENVIRONMENT_ID'; const user = { key: 'user' }; let platform; - let server; beforeEach(() => { - server = makeDefaultServer(); platform = stubPlatform.defaults(); }); - afterEach(() => { - server.restore(); - }); + async function withServers(asyncCallback) { + const pollServer = platform.testing.http.newServer(); + const eventsServer = platform.testing.http.newServer(); + pollServer.byDefault(respondJson({})); + eventsServer.byDefault(respond(202)); + const baseConfig = { baseUrl: pollServer.url, eventsUrl: eventsServer.url }; + return await asyncCallback(baseConfig, pollServer, eventsServer); + } + + async function withClient(user, extraConfig, asyncCallback) { + const client = platform.testing.makeClient(envName, user, extraConfig); + return await withCloseable(client, asyncCallback); + } it('should exist', () => { expect(LDClient).toBeDefined(); @@ -38,92 +39,98 @@ describe('LDClient', () => { describe('initialization', () => { it('should trigger the ready event', async () => { - const client = platform.testing.makeClient(envName, user); - const gotReady = promiseListener(); - client.on('ready', gotReady.callback); + await withServers(async baseConfig => { + await withClient(user, baseConfig, async client => { + const gotReady = eventSink(client, 'ready'); + await gotReady.take(); - await gotReady; - expect(platform.testing.logger.output.info).toEqual([messages.clientInitialized()]); + expect(platform.testing.logger.output.info).toEqual([messages.clientInitialized()]); + }); + }); }); it('should trigger the initialized event', async () => { - const client = platform.testing.makeClient(envName, user); - const gotInited = promiseListener(); - client.on('initialized', gotInited.callback); - - await gotInited; - }); - - it('should emit an error when an invalid samplingInterval is specified', async () => { - const client = platform.testing.makeClient(envName, user, { - samplingInterval: 'totally not a number', + await withServers(async baseConfig => { + await withClient(user, baseConfig, async client => { + const gotInited = eventSink(client, 'initialized'); + await gotInited.take(); + }); }); - const gotError = promiseListener(); - client.on('error', gotError.callback); - - const err = await gotError; - expect(err.message).toEqual('Invalid sampling interval configured. Sampling interval must be an integer >= 0.'); }); it('should emit an error when initialize is called without an environment key', async () => { const client = platform.testing.makeClient('', user); - const gotError = promiseListener(); - client.on('error', gotError.callback); + const gotError = eventSink(client, 'error'); - const err = await gotError; + const err = await gotError.take(); expect(err.message).toEqual(messages.environmentNotSpecified()); }); it('should emit an error when an invalid environment key is specified', async () => { - server.respondWith(errorResponse(404)); - - const client = platform.testing.makeClient('abc', user); - const gotError = promiseListener(); - client.on('error', gotError.callback); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respond(404)); + await withClient(user, baseConfig, async client => { + const gotError = eventSink(client, 'error'); - await expect(client.waitForInitialization()).rejects.toThrow(); + await expect(client.waitForInitialization()).rejects.toThrow(); - const err = await gotError; - expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); + const err = await gotError.take(); + expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); + }); + }); }); it('should emit a failure event when an invalid environment key is specified', async () => { - server.respondWith(errorResponse(404)); - - const client = platform.testing.makeClient('abc', user); - const gotFailed = promiseListener(); - client.on('failed', gotFailed.callback); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respond(404)); + await withClient(user, baseConfig, async client => { + const gotFailed = eventSink(client, 'failed'); - await expect(client.waitForInitialization()).rejects.toThrow(); + await expect(client.waitForInitialization()).rejects.toThrow(); - const err = await gotFailed; - expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); + const err = await gotFailed.take(); + expect(err).toEqual(new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound())); + }); + }); }); it('returns default values when an invalid environment key is specified', async () => { - server.respondWith(errorResponse(404)); - - const client = platform.testing.makeClient('abc', user); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respond(404)); + await withClient(user, baseConfig, async client => { + await expect(client.waitForInitialization()).rejects.toThrow(); - await expect(client.waitForInitialization()).rejects.toThrow(); - - expect(client.variation('flag-key', 1)).toEqual(1); + expect(client.variation('flag-key', 1)).toEqual(1); + }); + }); }); it('fetches flag settings if bootstrap is not provided (without reasons)', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(/sdk\/eval/.test(server.requests[0].url)).toEqual(true); - expect(/withReasons=true/.test(server.requests[0].url)).toEqual(false); + const flags = { flagKey: { value: true } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + const req = await pollServer.nextRequest(); + expect(req.path).toMatch(/sdk\/eval/); + expect(req.path).not.toMatch(/withReasons=true/); + }); + }); }); it('fetches flag settings if bootstrap is not provided (with reasons)', async () => { - const client = platform.testing.makeClient(envName, user, { evaluationReasons: true }); - await client.waitForInitialization(); - - expect(/sdk\/eval/.test(server.requests[0].url)).toEqual(true); - expect(/withReasons=true/.test(server.requests[0].url)).toEqual(true); + const flags = { flagKey: { value: true, variation: 1, reason: { kind: 'OFF' } } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, { ...baseConfig, evaluationReasons: true }, async client => { + await client.waitForInitialization(); + + const req = await pollServer.nextRequest(); + expect(req.path).toMatch(/sdk\/eval/); + expect(req.path).toMatch(/withReasons=true/); + }); + }); }); it('should contain package version', () => { @@ -133,51 +140,29 @@ describe('LDClient', () => { expect(result).toEqual(1); }); - it('should not warn when tracking a custom event', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - client.track('known'); - expect(platform.testing.logger.output.warn).toEqual([]); - }); - - it('should emit an error when tracking a non-string custom event', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - const badCustomEventKeys = [123, [], {}, null, undefined]; - badCustomEventKeys.forEach(key => { - platform.testing.logger.reset(); - client.track(key); - expect(platform.testing.logger.output.error).toEqual([messages.unknownCustomEventKey(key)]); - }); - }); - it('should emit an error event if there was an error fetching flags', async () => { - server.respondWith(errorResponse(503)); - - const client = platform.testing.makeClient(envName, user); - - const gotError = promiseListener(); - client.on('error', gotError.callback); - - await expect(client.waitForInitialization()).rejects.toThrow(); - await gotError; - }); - - it('should warn about missing user on first event', () => { - const client = platform.testing.makeClient(envName, null); - client.track('eventkey', null); - expect(platform.testing.logger.output.warn).toEqual([messages.eventWithoutUser()]); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respond(503)); + await withClient(user, baseConfig, async client => { + const gotError = eventSink(client, 'error'); + + await expect(client.waitForInitialization()).rejects.toThrow(); + const err = await gotError.take(); + expect(err).toEqual(new errors.LDFlagFetchError(messages.errorFetchingFlags(503))); + }); + }); }); async function verifyCustomHeader(sendLDHeaders, shouldGetHeaders) { - const client = platform.testing.makeClient(envName, user, { sendLDHeaders: sendLDHeaders }); - await client.waitForInitialization(); - const request = server.requests[0]; - expect(request.requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( - shouldGetHeaders ? utils.getLDUserAgentString(platform) : undefined - ); + await withServers(async (baseConfig, pollServer) => { + await withClient(user, { ...baseConfig, sendLDHeaders }, async client => { + await client.waitForInitialization(); + const request = await pollServer.nextRequest(); + expect(request.headers['x-launchdarkly-user-agent']).toEqual( + shouldGetHeaders ? utils.getLDUserAgentString(platform) : undefined + ); + }); + }); } it('sends custom header by default', () => verifyCustomHeader(undefined, true)); @@ -187,297 +172,348 @@ describe('LDClient', () => { it('does not send custom header if sendLDHeaders is false', () => verifyCustomHeader(undefined, true)); it('sanitizes the user', async () => { - const client = platform.testing.makeClient(envName, numericUser); - await client.waitForInitialization(); - expect(client.getUser()).toEqual(stringifiedNumericUser); + await withServers(async baseConfig => { + await withClient(numericUser, baseConfig, async client => { + await client.waitForInitialization(); + expect(client.getUser()).toEqual(stringifiedNumericUser); + }); + }); }); it('provides a persistent key for an anonymous user with no key', async () => { const anonUser = { anonymous: true, country: 'US' }; - const client0 = platform.testing.makeClient(envName, anonUser); - await client0.waitForInitialization(); - - const newUser0 = client0.getUser(); - expect(newUser0.key).toEqual(expect.anything()); - expect(newUser0).toMatchObject(anonUser); - - const client1 = platform.testing.makeClient(envName, anonUser); - await client1.waitForInitialization(); - - const newUser1 = client1.getUser(); - expect(newUser1).toEqual(newUser0); + await withServers(async baseConfig => { + let generatedUser; + await withClient(anonUser, baseConfig, async client0 => { + await client0.waitForInitialization(); + + generatedUser = client0.getUser(); + expect(generatedUser.key).toEqual(expect.anything()); + expect(generatedUser).toMatchObject(anonUser); + }); + await withClient(anonUser, baseConfig, async client1 => { + await client1.waitForInitialization(); + + const newUser1 = client1.getUser(); + expect(newUser1).toEqual(generatedUser); + }); + }); }); it('provides a key for an anonymous user with no key, even if local storage is unavailable', async () => { platform.localStorage = null; - const anonUser = { anonymous: true, country: 'US' }; - const client0 = platform.testing.makeClient(envName, anonUser); - await client0.waitForInitialization(); - const newUser0 = client0.getUser(); - expect(newUser0.key).toEqual(expect.anything()); - expect(newUser0).toMatchObject(anonUser); - - const client1 = platform.testing.makeClient(envName, anonUser); - await client1.waitForInitialization(); - - const newUser1 = client1.getUser(); - expect(newUser1.key).toEqual(expect.anything()); - // This key is probably different from newUser0.key, but that depends on execution time, so we can't count on it. - expect(newUser1).toMatchObject(anonUser); + await withServers(async baseConfig => { + let generatedUser; + await withClient(anonUser, baseConfig, async client0 => { + await client0.waitForInitialization(); + + generatedUser = client0.getUser(); + expect(generatedUser.key).toEqual(expect.anything()); + expect(generatedUser).toMatchObject(anonUser); + }); + await sleepAsync(100); // so that the time-based UUID algorithm will produce a different result below + await withClient(anonUser, baseConfig, async client1 => { + await client1.waitForInitialization(); + + const newUser1 = client1.getUser(); + expect(newUser1.key).toEqual(expect.anything()); + expect(newUser1.key).not.toEqual(generatedUser.key); + expect(newUser1).toMatchObject(anonUser); + }); + }); }); }); describe('initialization with bootstrap object', () => { it('should not fetch flag settings', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {} }); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer) => { + await withClient(user, { ...baseConfig, bootstrap: {} }, async client => { + await client.waitForInitialization(); - expect(server.requests.length).toEqual(0); + expect(pollServer.requests.length()).toEqual(0); + }); + }); }); it('makes flags available immediately before ready event', async () => { - const initData = makeBootstrap({ foo: { value: 'bar', version: 1 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - - expect(client.variation('foo')).toEqual('bar'); + await withServers(async baseConfig => { + const initData = makeBootstrap({ foo: { value: 'bar', version: 1 } }); + await withClient(user, { ...baseConfig, bootstrap: initData }, async client => { + expect(client.variation('foo')).toEqual('bar'); + }); + }); }); it('logs warning when bootstrap object uses old format', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: { foo: 'bar' } }); - await client.waitForInitialization(); + const initData = { foo: 'bar' }; + await withClient(user, { bootstrap: initData, sendEvents: false }, async client => { + await client.waitForInitialization(); - expect(platform.testing.logger.output.warn).toEqual([messages.bootstrapOldFormat()]); + expect(platform.testing.logger.output.warn).toEqual([messages.bootstrapOldFormat()]); + }); }); it('does not log warning when bootstrap object uses new format', async () => { const initData = makeBootstrap({ foo: { value: 'bar', version: 1 } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + await withClient(user, { bootstrap: initData, sendEvents: false }, async client => { + await client.waitForInitialization(); - expect(platform.testing.logger.output.warn).toEqual([]); - expect(client.variation('foo')).toEqual('bar'); + expect(platform.testing.logger.output.warn).toEqual([]); + expect(client.variation('foo')).toEqual('bar'); + }); }); }); describe('waitUntilReady', () => { it('should resolve waitUntilReady promise when ready', async () => { - const client = platform.testing.makeClient(envName, user); - const gotReady = promiseListener(); - client.on('ready', gotReady.callback); + await withServers(async baseConfig => { + await withClient(user, baseConfig, async client => { + const gotReady = eventSink(client, 'ready'); - await gotReady; - await client.waitUntilReady(); + await gotReady.take(); + await client.waitUntilReady(); + }); + }); }); }); describe('waitForInitialization', () => { it('resolves promise on successful init', async () => { - const client = platform.testing.makeClient(envName, user); - const gotReady = promiseListener(); - client.on('ready', gotReady.callback); + await withServers(async baseConfig => { + await withClient(user, baseConfig, async client => { + const gotReady = eventSink(client, 'ready'); - await gotReady; - await client.waitForInitialization(); + await gotReady.take(); + await client.waitForInitialization(); + }); + }); }); it('rejects promise if flags request fails', async () => { - server.respondWith(errorResponse(404)); - - const client = platform.testing.makeClient('abc', user); - const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); - await expect(client.waitForInitialization()).rejects.toThrow(err); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respond(404)); + await withClient(user, baseConfig, async client => { + const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); + await expect(client.waitForInitialization()).rejects.toThrow(err); + }); + }); }); }); describe('variation', () => { it('returns value for an existing flag - from bootstrap', async () => { - const client = platform.testing.makeClient(envName, user, { + const config = { bootstrap: makeBootstrap({ foo: { value: 'bar', version: 1 } }), - }); - await client.waitForInitialization(); + sendEvents: false, + }; + await withClient(user, config, async client => { + await client.waitForInitialization(); - expect(client.variation('foo')).toEqual('bar'); + expect(client.variation('foo')).toEqual('bar'); + }); }); it('returns value for an existing flag - from bootstrap with old format', async () => { - const client = platform.testing.makeClient(envName, user, { - bootstrap: { foo: 'bar' }, - }); - await client.waitForInitialization(); + const config = { + bootstrap: makeBootstrap({ foo: { value: 'bar', version: 1 } }), + sendEvents: false, + }; + await withClient(user, config, async client => { + await client.waitForInitialization(); - expect(client.variation('foo')).toEqual('bar'); + expect(client.variation('foo')).toEqual('bar'); + }); }); it('returns value for an existing flag - from polling', async () => { - server.respondWith(jsonResponse({ 'enable-foo': { value: true, version: 1, variation: 2 } })); - - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(client.variation('enable-foo', 1)).toEqual(true); + const flags = { 'enable-foo': { value: true, version: 1, variation: 2 } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + expect(client.variation('enable-foo', 1)).toEqual(true); + }); + }); }); it('returns default value for flag that had null value', async () => { - server.respondWith(jsonResponse({ 'enable-foo': { value: null, version: 1 } })); - - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(client.variation('foo', 'default')).toEqual('default'); + const flags = { 'enable-foo': { value: null, version: 1, variation: 2 } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + expect(client.variation('foo', 'default')).toEqual('default'); + }); + }); }); it('returns default value for unknown flag', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson({})); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); - expect(client.variation('foo', 'default')).toEqual('default'); + expect(client.variation('foo', 'default')).toEqual('default'); + }); + }); }); }); describe('variationDetail', () => { const reason = { kind: 'FALLTHROUGH' }; it('returns details for an existing flag - from bootstrap', async () => { - const client = platform.testing.makeClient(envName, user, { + const config = { bootstrap: makeBootstrap({ foo: { value: 'bar', version: 1, variation: 2, reason: reason } }), - }); - await client.waitForInitialization(); + }; + await withClient(user, config, async client => { + await client.waitForInitialization(); - expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); + expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); + }); }); it('returns details for an existing flag - from bootstrap with old format', async () => { - const client = platform.testing.makeClient(envName, user, { - bootstrap: { foo: 'bar' }, - }); - await client.waitForInitialization(); + const config = { bootstrap: { foo: 'bar' } }; + await withClient(user, config, async client => { + await client.waitForInitialization(); - expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: null, reason: null }); + expect(client.variationDetail('foo')).toEqual({ value: 'bar', variationIndex: null, reason: null }); + }); }); it('returns details for an existing flag - from polling', async () => { - const pollData = { foo: { value: 'bar', version: 1, variation: 2, reason: reason } }; - server.respondWith(jsonResponse(pollData)); - - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(client.variationDetail('foo', 'default')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); + const flags = { foo: { value: 'bar', version: 1, variation: 2, reason: reason } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + expect(client.variationDetail('foo', 'default')).toEqual({ value: 'bar', variationIndex: 2, reason: reason }); + }); + }); }); it('returns default value for flag that had null value', async () => { - server.respondWith(jsonResponse({ foo: { value: null, version: 1 } })); - - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(client.variationDetail('foo', 'default')).toEqual({ - value: 'default', - variationIndex: null, - reason: null, + const flags = { foo: { value: null, version: 1 } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + expect(client.variationDetail('foo', 'default')).toEqual({ + value: 'default', + variationIndex: null, + reason: null, + }); + }); }); }); it('returns default value and error for unknown flag', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - expect(client.variationDetail('foo', 'default')).toEqual({ - value: 'default', - variationIndex: null, - reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson({})); + await withClient(user, baseConfig, async client => { + expect(client.variationDetail('foo', 'default')).toEqual({ + value: 'default', + variationIndex: null, + reason: { kind: 'ERROR', errorKind: 'FLAG_NOT_FOUND' }, + }); + }); }); }); }); describe('allFlags', () => { it('returns flag values', async () => { - const initData = makeBootstrap({ key1: { value: 'value1' }, key2: { value: 'value2' } }); - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); - - expect(client.allFlags()).toEqual({ key1: 'value1', key2: 'value2' }); + const flags = { key1: { value: 'value1' }, key2: { value: 'value2' } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); + + expect(client.allFlags()).toEqual({ key1: 'value1', key2: 'value2' }); + }); + }); }); - it('returns empty map if client is not initialized', () => { - const client = platform.testing.makeClient(envName, user); - expect(client.allFlags()).toEqual({}); + it('returns empty map if client is not initialized', async () => { + const flags = { key1: { value: 'value1' }, key2: { value: 'value2' } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags)); + await withClient(user, baseConfig, async client => { + expect(client.allFlags()).toEqual({}); + }); + }); }); }); describe('identify', () => { it('updates flag values when the user changes', async () => { - const user2 = { key: 'user2' }; - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); - - await client.identify(user2); - expect(client.variation('enable-foo')).toEqual(true); - }); + const flags0 = { 'enable-foo': { value: false } }; + const flags1 = { 'enable-foo': { value: true } }; + const user1 = { key: 'user1' }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags0)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); - it('yields map of flag values as the result of identify()', async () => { - const user2 = { key: 'user2' }; - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + expect(client.variation('enable-foo')).toBe(false); - server.respondWith(jsonResponse({ 'enable-foo': { value: true } })); + pollServer.byDefault(respondJson(flags1)); - const flagMap = await client.identify(user2); - expect(flagMap).toEqual({ 'enable-foo': true }); - }); + const newFlagsMap = await client.identify(user1); - it('returns an error when identify is called with null user', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); + expect(client.variation('enable-foo')).toBe(true); - await expect(client.identify(null)).rejects.toThrow(); + expect(newFlagsMap).toEqual({ 'enable-foo': true }); + }); + }); }); - it('returns an error when identify is called with user with no key', async () => { - const client = platform.testing.makeClient(envName, user); - await client.waitForInitialization(); - - await expect(client.identify({ country: 'US' })).rejects.toThrow(); - }); + it('returns an error and does not update flags when identify is called with invalid user', async () => { + const flags0 = { 'enable-foo': { value: false } }; + const flags1 = { 'enable-foo': { value: true } }; + await withServers(async (baseConfig, pollServer) => { + pollServer.byDefault(respondJson(flags0)); + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); - it('does not change flag values after identify is called with null user', async () => { - const initData = { foo: 'bar' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + expect(client.variation('enable-foo')).toBe(false); + expect(pollServer.requests.length()).toEqual(1); - expect(client.variation('foo', 'x')).toEqual('bar'); + pollServer.byDefault(respondJson(flags1)); - await expect(client.identify(null)).rejects.toThrow(); + await expect(client.identify(null)).rejects.toThrow(); - expect(client.variation('foo', 'x')).toEqual('bar'); - }); + expect(client.variation('enable-foo')).toBe(false); + expect(pollServer.requests.length()).toEqual(1); - it('does not change flag values after identify is called with invalid user', async () => { - const initData = { foo: 'bar' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + const userWithNoKey = { country: 'US' }; + await expect(client.identify(userWithNoKey)).rejects.toThrow(); - expect(client.variation('foo', 'x')).toEqual('bar'); - - await expect(client.identify({ country: 'US' })).rejects.toThrow(); - - expect(client.variation('foo', 'x')).toEqual('bar'); + expect(client.variation('enable-foo')).toBe(false); + expect(pollServer.requests.length()).toEqual(1); + }); + }); }); it('provides a persistent key for an anonymous user with no key', async () => { - const initData = { foo: 'bar' }; - const client = platform.testing.makeClient(envName, user, { bootstrap: initData }); - await client.waitForInitialization(); + await withServers(async baseConfig => { + await withClient(user, baseConfig, async client => { + await client.waitForInitialization(); - const anonUser = { anonymous: true, country: 'US' }; - await client.identify(anonUser); + const anonUser = { anonymous: true, country: 'US' }; + await client.identify(anonUser); - const newUser = client.getUser(); - expect(newUser.key).toEqual(expect.anything()); - expect(newUser).toMatchObject(anonUser); + const newUser = client.getUser(); + expect(newUser.key).toEqual(expect.anything()); + expect(newUser).toMatchObject(anonUser); + }); + }); }); }); @@ -491,18 +527,24 @@ describe('LDClient', () => { }; const sp = stubPlatform.mockStateProvider(state); - const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer) => { + await withClient(null, { ...baseConfig, stateProvider: sp }, async client => { + await client.waitForInitialization(); - expect(client.variation('flagkey')).toEqual('value'); - expect(server.requests.length).toEqual(0); + expect(client.variation('flagkey')).toEqual('value'); + expect(pollServer.requests.length()).toEqual(0); + }); + }); }); - it('defers initialization if initial state not available, and does not make an HTTP request', () => { + it('defers initialization if initial state not available, and does not make an HTTP request', async () => { const sp = stubPlatform.mockStateProvider(null); - platform.testing.makeClient(null, null, { stateProvider: sp }); - expect(server.requests.length).toEqual(0); + await withServers(async (baseConfig, pollServer) => { + await withClient(null, { ...baseConfig, stateProvider: sp }, async () => { + expect(pollServer.requests.length()).toEqual(0); + }); + }); }); it('finishes initialization on receiving init event', async () => { @@ -514,12 +556,12 @@ describe('LDClient', () => { }; const sp = stubPlatform.mockStateProvider(null); - const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - - sp.emit('init', state); + await withClient(null, { stateProvider: sp, sendEvents: false }, async client => { + sp.emit('init', state); - await client.waitForInitialization(); - expect(client.variation('flagkey')).toEqual('value'); + await client.waitForInitialization(); + expect(client.variation('flagkey')).toEqual('value'); + }); }); it('updates flags on receiving update event', async () => { @@ -531,22 +573,22 @@ describe('LDClient', () => { }; const sp = stubPlatform.mockStateProvider(state0); - const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - await client.waitForInitialization(); + await withClient(null, { stateProvider: sp, sendEvents: false }, async client => { + await client.waitForInitialization(); - expect(client.variation('flagkey')).toEqual('value0'); + expect(client.variation('flagkey')).toEqual('value0'); - const state1 = { - flags: { flagkey: { value: 'value1' } }, - }; + const state1 = { + flags: { flagkey: { value: 'value1' } }, + }; - const gotChange = promiseListener(); - client.on('change:flagkey', gotChange.callback); + const gotChange = eventSink(client, 'change:flagkey'); - sp.emit('update', state1); + sp.emit('update', state1); - const args = await gotChange; - expect(args).toEqual(['value1', 'value0']); + const args = await gotChange.take(); + expect(args).toEqual(['value1', 'value0']); + }); }); it('disables identify()', async () => { @@ -555,16 +597,18 @@ describe('LDClient', () => { const state = { environment: 'env', user: user, flags: { flagkey: { value: 'value' } } }; const sp = stubPlatform.mockStateProvider(state); - const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - - sp.emit('init', state); + await withServers(async (baseConfig, pollServer) => { + await withClient(null, { ...baseConfig, stateProvider: sp }, async client => { + sp.emit('init', state); - await client.waitForInitialization(); - const newFlags = await client.identify(user1); + await client.waitForInitialization(); + const newFlags = await client.identify(user1); - expect(newFlags).toEqual({ flagkey: 'value' }); - expect(server.requests.length).toEqual(0); - expect(platform.testing.logger.output.warn).toEqual([messages.identifyDisabled()]); + expect(newFlags).toEqual({ flagkey: 'value' }); + expect(pollServer.requests.length()).toEqual(0); + expect(platform.testing.logger.output.warn).toEqual([messages.identifyDisabled()]); + }); + }); }); it('copies data from state provider to avoid unintentional object-sharing', async () => { @@ -576,65 +620,76 @@ describe('LDClient', () => { }; const sp = stubPlatform.mockStateProvider(null); - const client = platform.testing.makeClient(null, null, { stateProvider: sp }); - - sp.emit('init', state); + await withClient(null, { stateProvider: sp, sendEvents: false }, async client => { + sp.emit('init', state); - await client.waitForInitialization(); - expect(client.variation('flagkey')).toEqual('value'); + await client.waitForInitialization(); + expect(client.variation('flagkey')).toEqual('value'); - state.flags.flagkey = { value: 'secondValue' }; - expect(client.variation('flagkey')).toEqual('value'); + state.flags.flagkey = { value: 'secondValue' }; + expect(client.variation('flagkey')).toEqual('value'); - sp.emit('update', state); - expect(client.variation('flagkey')).toEqual('secondValue'); + sp.emit('update', state); + expect(client.variation('flagkey')).toEqual('secondValue'); - state.flags.flagkey = { value: 'thirdValue' }; - expect(client.variation('flagkey')).toEqual('secondValue'); + state.flags.flagkey = { value: 'thirdValue' }; + expect(client.variation('flagkey')).toEqual('secondValue'); + }); }); }); describe('close()', () => { it('flushes events', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); - await client.waitForInitialization(); - - await client.close(); - - expect(server.requests.length).toEqual(1); - const data = JSON.parse(server.requests[0].requestBody); - expect(data.length).toEqual(1); - expect(data[0].kind).toEqual('identify'); + await withServers(async (baseConfig, pollServer, eventsServer) => { + await withClient(user, { ...baseConfig, flushInterval: 100000 }, async client => { + await client.waitForInitialization(); + }); + + expect(eventsServer.requests.length()).toEqual(1); + const req = await eventsServer.nextRequest(); + const data = JSON.parse(req.body); + expect(data.length).toEqual(1); + expect(data[0].kind).toEqual('identify'); + }); }); it('does nothing if called twice', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer, eventsServer) => { + await withClient(user, { ...baseConfig, flushInterval: 100000 }, async client => { + await client.waitForInitialization(); - await client.close(); + await client.close(); - expect(server.requests.length).toEqual(1); + expect(eventsServer.requests.length()).toEqual(1); - await client.close(); + await client.close(); - expect(server.requests.length).toEqual(1); + expect(eventsServer.requests.length()).toEqual(1); + }); + }); }); it('is not rejected if flush fails', async () => { - server.respondWith(errorResponse(401)); - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer, eventsServer) => { + eventsServer.byDefault(respond(404)); + await withClient(user, { ...baseConfig, flushInterval: 100000 }, async client => { + await client.waitForInitialization(); - await client.close(); // shouldn't throw or have an unhandled rejection + await client.close(); // shouldn't throw or have an unhandled rejection + }); + }); }); it('can take a callback instead of returning a promise', async () => { - const client = platform.testing.makeClient(envName, user, { bootstrap: {}, flushInterval: 100000 }); - await client.waitForInitialization(); + await withServers(async (baseConfig, pollServer, eventsServer) => { + await withClient(user, { ...baseConfig }, async client => { + await client.waitForInitialization(); - await asyncify(cb => client.close(cb)); + await promisifySingle(client.close)(); - expect(server.requests.length).toEqual(1); + expect(eventsServer.requests.length()).toEqual(1); + }); + }); }); }); }); diff --git a/src/__tests__/Requestor-test.js b/src/__tests__/Requestor-test.js index 1641e50..5e42f92 100644 --- a/src/__tests__/Requestor-test.js +++ b/src/__tests__/Requestor-test.js @@ -1,255 +1,289 @@ -import * as stubPlatform from './stubPlatform'; -import { errorResponse, jsonResponse, makeDefaultServer } from './testUtils'; import Requestor from '../Requestor'; import * as errors from '../errors'; import * as messages from '../messages'; import * as utils from '../utils'; +import { fakeNetworkErrorValue, networkError, respond, respondJson } from './mockHttp'; +import * as stubPlatform from './stubPlatform'; + +// These tests verify that Requestor executes the expected HTTP requests to retrieve flags. Since +// the js-sdk-common package uses an abstraction of HTTP requests, these tests do not use HTTP but +// rather use a test implementation of our HTTP abstraction; the individual platform-specific SDKs +// are responsible for verifying that their own implementations of the same HTTP abstraction work +// correctly with real networking. + describe('Requestor', () => { - const baseUrl = 'http://requestee'; const user = { key: 'foo' }; const encodedUser = 'eyJrZXkiOiJmb28ifQ'; const env = 'FAKE_ENV'; const platform = stubPlatform.defaults(); - const logger = stubPlatform.logger(); - const defaultConfig = { - baseUrl: baseUrl, - logger: logger, - }; - let server; - - beforeEach(() => { - server = makeDefaultServer(); - }); - afterEach(() => { - server.restore(); - }); + async function withServer(asyncCallback) { + const server = platform.testing.http.newServer(); + server.byDefault(respondJson({})); + const baseConfig = { baseUrl: server.url, logger: stubPlatform.logger() }; + return await asyncCallback(baseConfig, server); + } it('resolves on success', async () => { - const requestor = Requestor(platform, defaultConfig, 'FAKE_ENV'); - await requestor.fetchFlagSettings({ key: 'user1' }, 'hash1'); - await requestor.fetchFlagSettings({ key: 'user2' }, 'hash2'); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, baseConfig, env); + + await requestor.fetchFlagSettings({ key: 'user1' }, 'hash1'); + await requestor.fetchFlagSettings({ key: 'user2' }, 'hash2'); - expect(server.requests).toHaveLength(2); + expect(server.requests.length()).toEqual(2); + }); }); it('makes requests with the GET verb if useReport is disabled', async () => { - const config = { ...defaultConfig, useReport: false }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: false }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].method).toEqual('GET'); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.method).toEqual('get'); + }); }); it('makes requests with the REPORT verb with a payload if useReport is enabled', async () => { - const config = { ...defaultConfig, useReport: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].method).toEqual('REPORT'); - expect(server.requests[0].requestBody).toEqual(JSON.stringify(user)); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.method).toEqual('report'); + expect(JSON.parse(req.body)).toEqual(user); + }); }); it('includes environment and user in GET URL', async () => { - const requestor = Requestor(platform, defaultConfig, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, baseConfig, env); - await requestor.fetchFlagSettings(user, null); + await requestor.fetchFlagSettings(user, null); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}`); + }); }); it('includes environment, user, and hash in GET URL', async () => { - const requestor = Requestor(platform, defaultConfig, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, baseConfig, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?h=hash1`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?h=hash1`); + }); }); it('includes environment, user, and withReasons in GET URL', async () => { - const config = { ...defaultConfig, evaluationReasons: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, evaluationReasons: true }, env); - await requestor.fetchFlagSettings(user, null); + await requestor.fetchFlagSettings(user, null); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?withReasons=true`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?withReasons=true`); + }); }); it('includes environment, user, hash, and withReasons in GET URL', async () => { - const config = { ...defaultConfig, evaluationReasons: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, evaluationReasons: true }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/users/${encodedUser}?h=hash1&withReasons=true`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/users/${encodedUser}?h=hash1&withReasons=true`); + }); }); it('includes environment in REPORT URL', async () => { - const config = { ...defaultConfig, useReport: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true }, env); - await requestor.fetchFlagSettings(user, null); + await requestor.fetchFlagSettings(user, null); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/user`); + }); }); it('includes environment and hash in REPORT URL', async () => { - const config = { ...defaultConfig, useReport: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?h=hash1`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/user?h=hash1`); + }); }); it('includes environment and withReasons in REPORT URL', async () => { - const config = { ...defaultConfig, useReport: true, evaluationReasons: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true, evaluationReasons: true }, env); - await requestor.fetchFlagSettings(user, null); + await requestor.fetchFlagSettings(user, null); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?withReasons=true`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/user?withReasons=true`); + }); }); it('includes environment, hash, and withReasons in REPORT URL', async () => { - const config = { ...defaultConfig, useReport: true, evaluationReasons: true }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true, evaluationReasons: true }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests).toHaveLength(1); - expect(server.requests[0].url).toEqual(`${baseUrl}/sdk/evalx/${env}/user?h=hash1&withReasons=true`); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.path).toEqual(`/sdk/evalx/${env}/user?h=hash1&withReasons=true`); + }); }); it('sends custom user-agent header in GET mode when sendLDHeaders is true', async () => { - const config = { ...defaultConfig, sendLDHeaders: true }; - const requestor = Requestor(platform, config, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, sendLDHeaders: true }, env); - expect(server.requests.length).toEqual(1); - expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( - utils.getLDUserAgentString(platform) - ); + await requestor.fetchFlagSettings(user); + + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.headers['x-launchdarkly-user-agent']).toEqual(utils.getLDUserAgentString(platform)); + }); }); it('sends custom user-agent header in REPORT mode when sendLDHeaders is true', async () => { - const config = { ...defaultConfig, useReport: true, sendLDHeaders: true }; - const requestor = Requestor(platform, config, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, useReport: true, sendLDHeaders: true }, env); + + await requestor.fetchFlagSettings(user, 'hash1'); - expect(server.requests.length).toEqual(1); - expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual( - utils.getLDUserAgentString(platform) - ); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.headers['x-launchdarkly-user-agent']).toEqual(utils.getLDUserAgentString(platform)); + }); }); it('does NOT send custom user-agent header when sendLDHeaders is false', async () => { - const config = { ...defaultConfig, useReport: true, sendLDHeaders: false }; - const requestor = Requestor(platform, config, env); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(platform, { ...baseConfig, sendLDHeaders: false }, env); - await requestor.fetchFlagSettings(user, 'hash1'); + await requestor.fetchFlagSettings(user); - expect(server.requests.length).toEqual(1); - expect(server.requests[0].requestHeaders['X-LaunchDarkly-User-Agent']).toEqual(undefined); + expect(server.requests.length()).toEqual(1); + const req = await server.requests.take(); + expect(req.headers['x-launchdarkly-user-agent']).toBeUndefined(); + }); }); it('returns parsed JSON response on success', async () => { - const requestor = Requestor(platform, defaultConfig, env); - const data = { foo: 'bar' }; - server.respondWith(jsonResponse(data)); + await withServer(async (baseConfig, server) => { + server.byDefault(respondJson(data)); + const requestor = Requestor(platform, baseConfig, env); - const result = await requestor.fetchFlagSettings(user); - expect(result).toEqual(data); + const result = await requestor.fetchFlagSettings(user); + expect(result).toEqual(data); + }); }); it('returns error for non-JSON content type', async () => { - const requestor = Requestor(platform, defaultConfig, env); - - server.respondWith([200, { 'Content-Type': 'text/html' }, '']); + await withServer(async (baseConfig, server) => { + server.byDefault(respond(200, { 'content-type': 'text/plain' }, 'sorry')); + const requestor = Requestor(platform, baseConfig, env); - const err = new errors.LDFlagFetchError(messages.invalidContentType('text/html')); - await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + const err = new errors.LDFlagFetchError(messages.invalidContentType('text/plain')); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); }); it('returns error for unspecified content type', async () => { - const requestor = Requestor(platform, defaultConfig, env); - - server.respondWith([200, {}, '{}']); + await withServer(async (baseConfig, server) => { + server.byDefault(respond(200, {}, '')); + const requestor = Requestor(platform, baseConfig, env); - const err = new errors.LDFlagFetchError(messages.invalidContentType('')); - await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + const err = new errors.LDFlagFetchError(messages.invalidContentType('')); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); }); it('signals specific error for 404 response', async () => { - const requestor = Requestor(platform, defaultConfig, env); - - server.respondWith(errorResponse(404)); + await withServer(async (baseConfig, server) => { + server.byDefault(respond(404)); + const requestor = Requestor(platform, baseConfig, env); - const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); - await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + const err = new errors.LDInvalidEnvironmentIdError(messages.environmentNotFound()); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); }); it('signals general error for non-404 error status', async () => { - const requestor = Requestor(platform, defaultConfig, env); + await withServer(async (baseConfig, server) => { + server.byDefault(respond(500)); + const requestor = Requestor(platform, baseConfig, env); - server.respondWith(errorResponse(500)); - - const err = new errors.LDFlagFetchError(messages.errorFetchingFlags('500')); - await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + const err = new errors.LDFlagFetchError(messages.errorFetchingFlags('500')); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); }); it('signals general error for network error', async () => { - const requestor = Requestor(platform, defaultConfig, env); + await withServer(async (baseConfig, server) => { + server.byDefault(networkError()); + const requestor = Requestor(platform, baseConfig, env); - server.respondWith(req => req.error()); - - const err = new errors.LDFlagFetchError(messages.networkError(new Error())); - await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + const err = new errors.LDFlagFetchError(messages.networkError(fakeNetworkErrorValue)); + await expect(requestor.fetchFlagSettings(user)).rejects.toThrow(err); + }); }); it('coalesces multiple requests so all callers get the latest result', async () => { - const requestor = Requestor(platform, defaultConfig, env); + await withServer(async (baseConfig, server) => { + let n = 0; + server.byDefault((req, res) => { + n++; + respondJson({ value: n })(req, res); + }); - let n = 0; - server.autoRespond = false; - server.respondWith(req => { - n++; - req.respond(...jsonResponse({ value: n })); - }); + const requestor = Requestor(platform, baseConfig, env); - const r1 = requestor.fetchFlagSettings(user); - const r2 = requestor.fetchFlagSettings(user); + const r1 = requestor.fetchFlagSettings(user); + const r2 = requestor.fetchFlagSettings(user); - server.respond(); - server.respond(); - // Note that we should only get a single response, { value: 1 } - Sinon does not call our respondWith - // function for the first request, because it's already been cancelled by the time the server looks - // at the request queue. The important thing is just that both requests get the same value. + const result1 = await r1; + const result2 = await r2; - const result1 = await r1; - const result2 = await r2; + expect(result1).toEqual({ value: 2 }); + expect(result2).toEqual({ value: 2 }); - expect(result1).toEqual({ value: n }); - expect(result2).toEqual({ value: n }); + expect(server.requests.length()).toEqual(2); + }); }); describe('When HTTP requests are not available at all', () => { it('fails on fetchFlagSettings', async () => { - const requestor = Requestor(stubPlatform.withoutHttp(), defaultConfig, env); - await expect(requestor.fetchFlagSettings(user, null)).rejects.toThrow(messages.httpUnavailable()); + await withServer(async (baseConfig, server) => { + const requestor = Requestor(stubPlatform.withoutHttp(), baseConfig, env); + await expect(requestor.fetchFlagSettings(user, null)).rejects.toThrow(messages.httpUnavailable()); + expect(server.requests.length()).toEqual(0); + }); }); }); }); diff --git a/src/__tests__/Stream-test.js b/src/__tests__/Stream-test.js index 394191f..00941f0 100644 --- a/src/__tests__/Stream-test.js +++ b/src/__tests__/Stream-test.js @@ -1,9 +1,10 @@ -import EventSource, { sources, resetSources } from './EventSource-mock'; -import * as stubPlatform from './stubPlatform'; -import { asyncify, asyncSleep } from './testUtils'; import * as messages from '../messages'; import Stream from '../Stream'; +import { sleepAsync } from 'launchdarkly-js-test-helpers'; +import EventSource from './EventSource-mock'; +import * as stubPlatform from './stubPlatform'; + const noop = () => {}; describe('Stream', () => { @@ -12,44 +13,16 @@ describe('Stream', () => { const user = { key: 'me' }; const encodedUser = 'eyJrZXkiOiJtZSJ9'; const hash = '012345789abcde'; + const defaultConfig = { streamUrl: baseUrl }; let logger; - let defaultConfig; let platform; beforeEach(() => { logger = stubPlatform.logger(); - defaultConfig = { streamUrl: baseUrl, logger }; - resetSources(); + defaultConfig.logger = logger; platform = stubPlatform.defaults(); }); - function expectStream(url) { - if (sources[url]) { - return sources[url]; - } else { - throw new Error( - 'Did not open stream with expected URL of ' + url + '; active streams are: ' + Object.keys(sources).join(', ') - ); - } - } - - function expectOneStream() { - const keys = Object.keys(sources); - if (keys.length !== 1) { - throw new Error('Expected only one stream; active streams are: ' + keys.join(', ')); - } - return sources[keys[0]]; - } - - function onNewEventSource(f) { - const factory = platform.eventSourceFactory; - platform.eventSourceFactory = (url, options) => { - const es = factory(url, options); - f(es, url, options); - return es; - }; - } - it('should not throw on EventSource when it does not exist', () => { const platform1 = { ...platform }; delete platform1['eventSourceFactory']; @@ -72,41 +45,45 @@ describe('Stream', () => { expect(disconnect).not.toThrow(TypeError); }); - it('connects to EventSource with eval stream URL by default', () => { + it('connects to EventSource with eval stream URL by default', async () => { const stream = new Stream(platform, defaultConfig, envName); stream.connect(user, {}); - const es = expectStream(baseUrl + '/eval/' + envName + '/' + encodedUser); - expect(es.options).toEqual({}); + const created = await platform.testing.expectStream(baseUrl + '/eval/' + envName + '/' + encodedUser); + expect(created.options).toEqual({}); }); - it('adds secure mode hash to URL if provided', () => { + it('adds secure mode hash to URL if provided', async () => { const stream = new Stream(platform, defaultConfig, envName, hash); stream.connect(user, {}); - expectStream(baseUrl + '/eval/' + envName + '/' + encodedUser + '?h=' + hash); + const created = await platform.testing.expectStream( + baseUrl + '/eval/' + envName + '/' + encodedUser + '?h=' + hash + ); + expect(created.options).toEqual({}); }); - it('falls back to ping stream URL if useReport is true and REPORT is not supported', () => { + it('falls back to ping stream URL if useReport is true and REPORT is not supported', async () => { const config = { ...defaultConfig, useReport: true }; - const stream = new Stream(platform, config, envName, hash); + const stream = new Stream(platform, config, envName); stream.connect(user, {}); - expectStream(baseUrl + '/ping/' + envName); + const created = await platform.testing.expectStream(baseUrl + '/ping/' + envName); + expect(created.options).toEqual({}); }); - it('sends request body if useReport is true and REPORT is supported', () => { + it('sends request body if useReport is true and REPORT is supported', async () => { const platform1 = { ...platform, eventSourceAllowsReport: true }; const config = { ...defaultConfig, useReport: true }; const stream = new Stream(platform1, config, envName); stream.connect(user, {}); - const es = expectStream(baseUrl + '/eval/' + envName); - expect(es.options.method).toEqual('REPORT'); - expect(JSON.parse(es.options.body)).toEqual(user); + const created = await platform.testing.expectStream(baseUrl + '/eval/' + envName); + expect(created.options.method).toEqual('REPORT'); + expect(JSON.parse(created.options.body)).toEqual(user); }); - it('sets event listeners', () => { + it('sets event listeners', async () => { const stream = new Stream(platform, defaultConfig, envName); const fn1 = jest.fn(); const fn2 = jest.fn(); @@ -116,7 +93,8 @@ describe('Stream', () => { anniversary: fn2, }); - const es = expectOneStream(); + const created = await platform.testing.expectStream(); + const es = created.eventSource; es.mockEmit('birthday'); expect(fn1).toHaveBeenCalled(); @@ -131,23 +109,24 @@ describe('Stream', () => { const stream = new Stream(platform, config, envName); stream.connect(user); - let es = expectOneStream(); + const created = await platform.testing.expectStream(); + let es = created.eventSource; + expect(es.readyState).toBe(EventSource.CONNECTING); es.mockOpen(); expect(es.readyState).toBe(EventSource.OPEN); const nAttempts = 5; for (let i = 0; i < nAttempts; i++) { - const newEventSourcePromise = asyncify(onNewEventSource); - es.mockError('test error'); - const es1 = await newEventSourcePromise; + const created1 = await platform.testing.expectStream(); + const es1 = created1.eventSource; expect(es.readyState).toBe(EventSource.CLOSED); expect(es1.readyState).toBe(EventSource.CONNECTING); es1.mockOpen(); - await asyncSleep(0); // make sure the stream logic has a chance to catch up with the new EventSource state + await sleepAsync(0); // make sure the stream logic has a chance to catch up with the new EventSource state expect(stream.isConnected()).toBe(true); @@ -160,15 +139,15 @@ describe('Stream', () => { const stream = new Stream(platform, config, envName); stream.connect(user); - let es = expectOneStream(); + const created = await platform.testing.expectStream(); + let es = created.eventSource; es.mockOpen(); const nAttempts = 5; for (let i = 0; i < nAttempts; i++) { - const newEventSourcePromise = asyncify(onNewEventSource); - es.mockError('test error'); - es = await newEventSourcePromise; + const created1 = await platform.testing.expectStream(); + es = created1.eventSource; es.mockOpen(); } @@ -184,15 +163,15 @@ describe('Stream', () => { put: fakePut, }); - let es = expectOneStream(); + const created = await platform.testing.expectStream(); + let es = created.eventSource; es.mockOpen(); const nAttempts = 5; for (let i = 0; i < nAttempts; i++) { - const newEventSourcePromise = asyncify(onNewEventSource); - es.mockError('test error #1'); - es = await newEventSourcePromise; + const created1 = await platform.testing.expectStream(); + es = created1.eventSource; es.mockOpen(); } @@ -201,10 +180,9 @@ describe('Stream', () => { expect(fakePut).toHaveBeenCalled(); for (let i = 0; i < nAttempts; i++) { - const newEventSourcePromise = asyncify(onNewEventSource); - es.mockError('test error #2'); - es = await newEventSourcePromise; + const created1 = await platform.testing.expectStream(); + es = created1.eventSource; es.mockOpen(); } diff --git a/src/__tests__/configuration-test.js b/src/__tests__/configuration-test.js index a3fd16b..b17e9cc 100644 --- a/src/__tests__/configuration-test.js +++ b/src/__tests__/configuration-test.js @@ -1,55 +1,65 @@ -import * as stubPlatform from './stubPlatform'; +import { sleepAsync, eventSink } from 'launchdarkly-js-test-helpers'; + import * as configuration from '../configuration'; +import { LDInvalidArgumentError } from '../errors'; import * as messages from '../messages'; import EventEmitter from '../EventEmitter'; -describe('configuration', () => { - let logger; - - beforeEach(() => { - logger = stubPlatform.logger(); - }); +import * as stubPlatform from './stubPlatform'; - function checkDefault(name, defaultValue, specificValue) { - it('applies defaults correctly for "' + name + "'", () => { - const configWithUnspecifiedValue = {}; - expect(configuration.validate(configWithUnspecifiedValue)[name]).toBe(defaultValue); - const configWithNullValue = {}; - configWithNullValue[name] = null; - expect(configuration.validate(configWithNullValue)[name]).toBe(defaultValue); - const configWithSpecifiedValue = {}; - configWithSpecifiedValue[name] = specificValue; - expect(configuration.validate(configWithSpecifiedValue)[name]).toBe(specificValue); - }); +describe('configuration', () => { + function errorListener() { + const logger = stubPlatform.logger(); + const emitter = EventEmitter(logger); + const errorQueue = eventSink(emitter, 'error'); + return { + emitter, + logger, + expectNoErrors: async () => { + await sleepAsync(0); // errors are dispatched on next tick + expect(errorQueue.length()).toEqual(0); + expect(logger.output.error).toEqual([]); + }, + expectError: async message => { + await sleepAsync(0); + expect(errorQueue.length()).toEqual(1); + if (message) { + expect(await errorQueue.take()).toEqual(new LDInvalidArgumentError(message)); + } else { + expect((await errorQueue.take()).constructor.prototype.name).toEqual('LaunchDarklyInvalidArgumentError'); + } + }, + expectWarningOnly: async message => { + await sleepAsync(0); + expect(errorQueue.length()).toEqual(0); + expect(logger.output.warn).toContain(message); + }, + }; } - checkDefault('sendEvents', true, false); - checkDefault('sendLDHeaders', true, false); - checkDefault('inlineUsersInEvents', false, true); - checkDefault('allowFrequentDuplicateEvents', false, true); - checkDefault('sendEventsOnlyForVariation', false, true); - checkDefault('useReport', false, true); - checkDefault('evaluationReasons', false, true); - checkDefault('flushInterval', 2000, 3000); - checkDefault('samplingInterval', 0, 1); - checkDefault('streamReconnectDelay', 1000, 2000); + async function expectDefault(name) { + const listener = errorListener(); + const config = configuration.validate({}, listener.emitter, null, listener.logger); + expect(config[name]).toBe(configuration.baseOptionDefs[name].default); + await listener.expectNoErrors(); + } function checkDeprecated(oldName, newName, value) { const desc = newName ? 'allows "' + oldName + '" as a deprecated equivalent to "' + newName + '"' : 'warns that "' + oldName + '" is deprecated'; - it(desc, () => { + it(desc, async () => { + const listener = errorListener(); const config0 = {}; config0[oldName] = value; - logger.reset(); - const config1 = configuration.validate(config0, null, null, logger); + const config1 = configuration.validate(config0, listener.emitter, null, listener.logger); if (newName) { expect(config1[newName]).toBe(value); expect(config1[oldName]).toBeUndefined(); } else { expect(config1[oldName]).toEqual(value); } - expect(logger.output.warn).toEqual([messages.deprecated(oldName, newName)]); + await listener.expectWarningOnly(messages.deprecated(oldName, newName)); }); } @@ -57,23 +67,134 @@ describe('configuration', () => { checkDeprecated('private_attribute_names', 'privateAttributeNames', ['foo']); checkDeprecated('samplingInterval', null, 100); - function checkInvalidValue(name, badValue, goodValue, done) { - const emitter = EventEmitter(); - emitter.on('error', e => { - expect(e.constructor.prototype.name).toBe('LaunchDarklyInvalidArgumentError'); - done(); + function checkBooleanProperty(name) { + it('enforces boolean type and default for "' + name + '"', async () => { + await expectDefault(name); + + let listener = errorListener(); + const configIn1 = {}; + configIn1[name] = true; + const config1 = configuration.validate(configIn1, listener.emitter, null, listener.logger); + expect(config1[name]).toBe(true); + await listener.expectNoErrors(); + + listener = errorListener(); + const configIn2 = {}; + configIn2[name] = false; + const config2 = configuration.validate(configIn2, listener.emitter, null, listener.logger); + expect(config2[name]).toBe(false); + await listener.expectNoErrors(); + + listener = errorListener(); + const configIn3 = {}; + configIn3[name] = 'abc'; + const config3 = configuration.validate(configIn3, listener.emitter, null, listener.logger); + expect(config3[name]).toBe(true); + await listener.expectError(messages.wrongOptionTypeBoolean(name, 'string')); + + listener = errorListener(); + const configIn4 = {}; + configIn4[name] = 0; + const config4 = configuration.validate(configIn4, listener.emitter, null, listener.logger); + expect(config4[name]).toBe(false); + await listener.expectError(messages.wrongOptionTypeBoolean(name, 'number')); + }); + } + + checkBooleanProperty('sendEvents'); + checkBooleanProperty('allAttributesPrivate'); + checkBooleanProperty('sendLDHeaders'); + checkBooleanProperty('inlineUsersInEvents'); + checkBooleanProperty('allowFrequentDuplicateEvents'); + checkBooleanProperty('sendEventsOnlyForVariation'); + checkBooleanProperty('useReport'); + checkBooleanProperty('evaluationReasons'); + + function checkNumericProperty(name, validValue) { + it('enforces numeric type and default for "' + name + '"', async () => { + await expectDefault(name); + + let listener = errorListener(); + const configIn1 = {}; + configIn1[name] = validValue; + const config1 = configuration.validate(configIn1, listener.emitter, null, listener.logger); + expect(config1[name]).toBe(validValue); + await listener.expectNoErrors(); + + listener = errorListener(); + const configIn2 = {}; + configIn2[name] = 'no'; + const config2 = configuration.validate(configIn2, listener.emitter, null, listener.logger); + expect(config2[name]).toBe(configuration.baseOptionDefs[name].default); + await listener.expectError(messages.wrongOptionType(name, 'number', 'string')); + }); + } + + checkNumericProperty('eventCapacity', 200); + checkNumericProperty('flushInterval', 3000); + checkNumericProperty('samplingInterval', 1); + checkNumericProperty('streamReconnectDelay', 2000); + + function checkMinimumValue(name, minimum) { + it('disallows value below minimum of ' + minimum + ' for ' + name, async () => { + const listener = errorListener(); + const configIn = {}; + configIn[name] = minimum - 1; + const config = configuration.validate(configIn, listener.emitter, null, listener.logger); + await listener.expectError(messages.optionBelowMinimum(name, minimum - 1, minimum)); + expect(config[name]).toBe(minimum); }); - const config = {}; - config[name] = badValue; - const config1 = configuration.validate(config, emitter); - expect(config1[name]).toBe(goodValue); } - it('enforces minimum flush interval', done => { - checkInvalidValue('flushInterval', 1999, 2000, done); + checkMinimumValue('eventCapacity', 1); + checkMinimumValue('flushInterval', 2000); + checkMinimumValue('samplingInterval', 0); + + function checkValidValue(name, goodValue) { + it('allows value of ' + JSON.stringify(goodValue) + ' for ' + name, async () => { + const listener = errorListener(); + const configIn = {}; + configIn[name] = goodValue; + const config = configuration.validate(configIn, listener.emitter, null, listener.logger); + await listener.expectNoErrors(); + expect(config[name]).toBe(goodValue); + }); + } + + checkValidValue('bootstrap', 'localstorage'); + checkValidValue('bootstrap', { flag: 'value' }); + + it('complains if you set an unknown property', async () => { + const listener = errorListener(); + const configIn = { unsupportedThing: true }; + const config = configuration.validate(configIn, listener.emitter, null, listener.logger); + await listener.expectError(messages.unknownOption('unsupportedThing')); + expect(config.unsupportedThing).toBe(true); }); - it('disallows negative sampling interval', done => { - checkInvalidValue('samplingInterval', -1, 0, done); + it('allows platform-specific SDK options whose defaults are specified by the SDK', async () => { + const listener = errorListener(); + const fn = () => {}; + const platformSpecificOptions = { + extraBooleanOption: { default: true }, + extraNumericOption: { default: 2 }, + extraNumericOptionWithoutDefault: { type: 'number' }, + extraStringOption: { default: 'yes' }, + extraStringOptionWithoutDefault: { type: 'string' }, + extraFunctionOption: { type: 'function' }, + }; + const configIn = { + extraBooleanOption: false, + extraNumericOptionWithoutDefault: 'not a number', + extraStringOptionWithoutDefault: 'ok', + extraFunctionOption: fn, + }; + const config = configuration.validate(configIn, listener.emitter, platformSpecificOptions, listener.logger); + expect(config.extraBooleanOption).toBe(false); + expect(config.extraNumericOption).toBe(2); + expect(config.extraStringOption).toBe('yes'); + expect(config.extraStringOptionWithoutDefault).toBe('ok'); + expect(config.extraFunctionOption).toBe(fn); + await listener.expectError(messages.wrongOptionType('extraNumericOptionWithoutDefault', 'number', 'string')); }); }); diff --git a/src/__tests__/mockHttp.js b/src/__tests__/mockHttp.js new file mode 100644 index 0000000..c0d7dc3 --- /dev/null +++ b/src/__tests__/mockHttp.js @@ -0,0 +1,122 @@ +import * as url from 'url'; +import { AsyncQueue } from 'launchdarkly-js-test-helpers'; + +// The js-sdk-common package does not do any HTTP requests itself, because the implementation of +// HTTP is platform-dependent and must be provided by the individual SDKs (e.g. the browser SDK, +// which uses XMLHttpRequest, versus the Electron SDK, which uses Node HTTP). So, for testing +// this package, there is no point in using an HTTP capture tool like Sinon or a real embedded +// HTTP server. Instead we use this simple implementation of the abstraction, which lets us set +// up test handlers with a syntax that imitates our launchdarkly-js-test-helpers HTTP server. + +let lastServerId = 0; + +export function MockHttpState() { + const servers = {}; + + return { + newServer: () => { + lastServerId++; + const hostname = 'mock-server-' + lastServerId; + const server = newMockServer(hostname); + servers[hostname] = server; + return server; + }, + + doRequest: (method, requestUrl, headers, body, synchronous) => { + const urlParams = url.parse(requestUrl); + const server = servers[urlParams.host]; + if (!server) { + return { promise: Promise.reject('unknown host: ' + urlParams.host) }; + } + return server._doRequest(method, urlParams, headers, body, synchronous); + }, + }; +} + +function newMockServer(hostname) { + let defaultHandler = respond(404); + const matchers = []; + const requests = new AsyncQueue(); + + function dispatch(req, resp) { + for (const i in matchers) { + if (matchers[i](req, resp)) { + return; + } + } + defaultHandler(req, resp); + } + + const server = { + url: 'http://' + hostname, + + requests, + + nextRequest: async () => await requests.take(), + + byDefault: handler => { + defaultHandler = handler; + return server; + }, + + forMethodAndPath: (method, path, handler) => { + const matcher = (req, resp) => { + if (req.method === method.toLowerCase() && req.path === path) { + handler(req, resp); + return true; + } + return false; + }; + matchers.push(matcher); + return server; + }, + + close: () => {}, // currently we don't need to clean up the server state + + _doRequest: (method, urlParams, headers, body) => { + const transformedHeaders = {}; + Object.keys(headers || {}).forEach(key => { + transformedHeaders[key.toLowerCase()] = headers[key]; + }); + const req = { + method: method.toLowerCase(), + path: urlParams.path, + headers: transformedHeaders, + body, + }; + requests.add(req); + const ret = {}; + ret.promise = new Promise((resolve, reject) => { + const resp = { resolve, reject }; + dispatch(req, resp); + }); + return ret; + }, + }; + + return server; +} + +export function respond(status, headers, body) { + return (req, resp) => { + const respProps = { + // these are the properties our HTTP abstraction expects + status, + header: name => headers && headers[name.toLowerCase()], + body, + }; + resp.resolve(respProps); + }; +} + +export function respondJson(data) { + return respond(200, { 'content-type': 'application/json' }, JSON.stringify(data)); +} + +export const fakeNetworkErrorValue = new Error('fake network error'); + +export function networkError() { + return (req, resp) => { + resp.reject(fakeNetworkErrorValue); + }; +} diff --git a/src/__tests__/stubPlatform.js b/src/__tests__/stubPlatform.js index 7275a00..d82e1da 100644 --- a/src/__tests__/stubPlatform.js +++ b/src/__tests__/stubPlatform.js @@ -1,10 +1,10 @@ -import sinon from 'sinon'; -import EventSource from './EventSource-mock'; import * as LDClient from '../index'; import EventEmitter from '../EventEmitter'; -const sinonXhr = sinon.useFakeXMLHttpRequest(); -sinonXhr.restore(); +import { AsyncQueue, sleepAsync } from 'launchdarkly-js-test-helpers'; + +import EventSource from './EventSource-mock'; +import { MockHttpState } from './mockHttp'; // This file provides a stub implementation of the internal platform API for use in tests. // @@ -30,11 +30,13 @@ sinonXhr.restore(); export function defaults() { const localStore = {}; + const mockHttpState = MockHttpState(); + const eventSourcesCreated = new AsyncQueue(); let currentUrl = null; let doNotTrack = false; const p = { - httpRequest: newHttpRequest, + httpRequest: mockHttpState.doRequest, httpAllowsPost: () => true, httpAllowsSync: () => true, getCurrentUrl: () => currentUrl, @@ -42,6 +44,7 @@ export function defaults() { eventSourceFactory: (url, options) => { const es = new EventSource(url); es.options = options; + eventSourcesCreated.add({ eventSource: es, url, options }); return es; }, eventSourceIsActive: es => es.readyState === EventSource.OPEN || es.readyState === EventSource.CONNECTING, @@ -67,6 +70,10 @@ export function defaults() { testing: { logger: logger(), + http: mockHttpState, + + eventSourcesCreated, + makeClient: (env, user, options = {}) => { const config = { logger: p.testing.logger, ...options }; return LDClient.initialize(env, user, config, p).client; @@ -85,6 +92,16 @@ export function defaults() { setLocalStorageImmediately: (key, value) => { localStore[key] = value; }, + + expectStream: async url => { + await sleepAsync(0); // in case the stream is created by a deferred task + expect(eventSourcesCreated.length()).toBeGreaterThanOrEqual(1); + const created = await eventSourcesCreated.take(); + if (url) { + expect(created.url).toEqual(url); + } + return created; + }, }, }; return p; @@ -113,52 +130,3 @@ export function mockStateProvider(initialState) { sp.getInitialState = () => initialState; return sp; } - -// This HTTP implementation is basically the same one that's used in the browser client, but it's -// made to interact with Sinon, so that the tests can use the familiar Sinon API. -// -// It'd be nice to be able to reuse this same logic in the browser client instead of copying it, -// but it's not of any use in Node or Electron so it doesn't really belong in the common package. - -function newHttpRequest(method, url, headers, body, synchronous) { - const xhr = new sinonXhr(); - xhr.open(method, url, !synchronous); - for (const key in headers || {}) { - if (headers.hasOwnProperty(key)) { - xhr.setRequestHeader(key, headers[key]); - } - } - if (synchronous) { - const p = new Promise(resolve => { - xhr.send(body); - resolve(); - }); - return { promise: p }; - } else { - let cancelled; - const p = new Promise((resolve, reject) => { - xhr.addEventListener('load', () => { - if (cancelled) { - return; - } - resolve({ - status: xhr.status, - header: key => xhr.getResponseHeader(key), - body: xhr.responseText, - }); - }); - xhr.addEventListener('error', () => { - if (cancelled) { - return; - } - reject(new Error()); - }); - xhr.send(body); - }); - const cancel = () => { - cancelled = true; - xhr.abort(); - }; - return { promise: p, cancel: cancel }; - } -} diff --git a/src/__tests__/testUtils.js b/src/__tests__/testUtils.js index e4a944d..b86fc55 100644 --- a/src/__tests__/testUtils.js +++ b/src/__tests__/testUtils.js @@ -1,31 +1,3 @@ -import sinon from 'sinon'; - -export function asyncSleep(delay) { - return new Promise(resolve => { - setTimeout(resolve, delay); - }); -} - -export function asyncify(f) { - return new Promise(resolve => f(resolve)); -} - -export function errorResponse(status) { - return [status, {}, '']; -} - -export function jsonResponse(data) { - return [200, { 'Content-Type': 'application/json' }, JSON.stringify(data)]; -} - -export function makeDefaultServer() { - const server = sinon.createFakeServer(); - server.autoRespond = true; - server.autoRespondAfter = 0; - server.respondWith(jsonResponse({})); // default 200 response for tests that don't specify otherwise - return server; -} - export const numericUser = { key: 1, secondary: 2, diff --git a/src/configuration.js b/src/configuration.js index ad182fd..d5e85db 100644 --- a/src/configuration.js +++ b/src/configuration.js @@ -2,25 +2,38 @@ import * as errors from './errors'; import * as messages from './messages'; import * as utils from './utils'; -export function validate(options, emitter, extraDefaults, logger) { - const baseDefaults = { - baseUrl: 'https://app.launchdarkly.com', - streamUrl: 'https://clientstream.launchdarkly.com', - eventsUrl: 'https://events.launchdarkly.com', - sendEvents: true, - sendLDHeaders: true, - inlineUsersInEvents: false, - allowFrequentDuplicateEvents: false, - sendEventsOnlyForVariation: false, - useReport: false, - evaluationReasons: false, - flushInterval: 2000, - samplingInterval: 0, - streamReconnectDelay: 1000, - allAttributesPrivate: false, - privateAttributeNames: [], - }; - const defaults = utils.extend({ logger: logger }, baseDefaults, extraDefaults); +// baseOptionDefs should contain an entry for each supported configuration option in the common package. +// Each entry can have three properties: +// - "default": the default value if any +// - "type": a type constraint used if the type can't be inferred from the default value). The allowable +// values are "boolean", "string", "number", "array", "object", "function", or several of these OR'd +// together with "|" ("function|object"). +// - "minimum": minimum value if any for numeric properties +// +// The extraOptionDefs parameter to validate() uses the same format. +export const baseOptionDefs = { + baseUrl: { default: 'https://app.launchdarkly.com' }, + streamUrl: { default: 'https://clientstream.launchdarkly.com' }, + eventsUrl: { default: 'https://events.launchdarkly.com' }, + sendEvents: { default: true }, + sendLDHeaders: { default: true }, + inlineUsersInEvents: { default: false }, + allowFrequentDuplicateEvents: { default: false }, + sendEventsOnlyForVariation: { default: false }, + useReport: { default: false }, + evaluationReasons: { default: false }, + eventCapacity: { default: 100, minimum: 1 }, + flushInterval: { default: 2000, minimum: 2000 }, + samplingInterval: { default: 0, minimum: 0 }, + streamReconnectDelay: { default: 1000, minimum: 0 }, + allAttributesPrivate: { default: false }, + privateAttributeNames: { default: [] }, + bootstrap: { type: 'string|object' }, + stateProvider: { type: 'object' }, // not a public option, used internally +}; + +export function validate(options, emitter, extraOptionDefs, logger) { + const optionDefs = utils.extend({ logger: { default: logger } }, baseOptionDefs, extraOptionDefs); const deprecatedOptions = { // eslint-disable-next-line camelcase @@ -46,14 +59,64 @@ export function validate(options, emitter, extraDefaults, logger) { }); } - function applyDefaults(config, defaults) { - // This works differently from utils.extend() in that it *will* override a default value + function applyDefaults(config) { + // This works differently from utils.extend() in that it *will not* override a default value // if the provided value is explicitly set to null. This provides backward compatibility // since in the past we only used the provided values if they were truthy. const ret = utils.extend({}, config); - Object.keys(defaults).forEach(name => { + Object.keys(optionDefs).forEach(name => { if (ret[name] === undefined || ret[name] === null) { - ret[name] = defaults[name]; + ret[name] = optionDefs[name] && optionDefs[name].default; + } + }); + return ret; + } + + function validateTypesAndNames(config) { + const ret = utils.extend({}, config); + const typeDescForValue = value => { + if (value === null) { + return 'any'; + } + if (value === undefined) { + return undefined; + } + if (Array.isArray(value)) { + return 'array'; + } + const t = typeof value; + if (t === 'boolean' || t === 'string' || t === 'number' || t === 'function') { + return t; + } + return 'object'; + }; + Object.keys(config).forEach(name => { + const value = config[name]; + if (value !== null && value !== undefined) { + const optionDef = optionDefs[name]; + if (optionDef === undefined) { + reportArgumentError(messages.unknownOption(name)); + } else { + const expectedType = optionDef.type || typeDescForValue(optionDef.default); + if (expectedType !== 'any') { + const allowedTypes = expectedType.split('|'); + const actualType = typeDescForValue(value); + if (allowedTypes.indexOf(actualType) < 0) { + if (expectedType === 'boolean') { + ret[name] = !!value; + reportArgumentError(messages.wrongOptionTypeBoolean(name, actualType)); + } else { + reportArgumentError(messages.wrongOptionType(name, expectedType, actualType)); + ret[name] = optionDef.default; + } + } else { + if (actualType === 'number' && optionDef.minimum !== undefined && value < optionDef.minimum) { + reportArgumentError(messages.optionBelowMinimum(name, value, optionDef.minimum)); + ret[name] = optionDef.minimum; + } + } + } + } } }); return ret; @@ -69,16 +132,8 @@ export function validate(options, emitter, extraDefaults, logger) { checkDeprecatedOptions(config); - config = applyDefaults(config, defaults); - - if (isNaN(config.flushInterval) || config.flushInterval < 2000) { - config.flushInterval = 2000; - reportArgumentError('Invalid flush interval configured. Must be an integer >= 2000 (milliseconds).'); - } - if (isNaN(config.samplingInterval) || config.samplingInterval < 0) { - config.samplingInterval = 0; - reportArgumentError('Invalid sampling interval configured. Sampling interval must be an integer >= 0.'); - } + config = applyDefaults(config); + config = validateTypesAndNames(config); return config; } diff --git a/src/index.js b/src/index.js index 9cbbad6..e2fcdd7 100644 --- a/src/index.js +++ b/src/index.js @@ -26,10 +26,10 @@ const internalChangeEvent = 'internal-change'; // // For definitions of the API in the platform object, see stubPlatform.js in the test code. -export function initialize(env, user, specifiedOptions, platform, extraDefaults) { +export function initialize(env, user, specifiedOptions, platform, extraOptionDefs) { const logger = createLogger(); const emitter = EventEmitter(logger); - const options = configuration.validate(specifiedOptions, emitter, extraDefaults, logger); + const options = configuration.validate(specifiedOptions, emitter, extraOptionDefs, logger); const hash = options.hash; const sendEvents = options.sendEvents; let environment = env; @@ -69,7 +69,7 @@ export function initialize(env, user, specifiedOptions, platform, extraDefaults) if (specifiedOptions && specifiedOptions.logger) { return specifiedOptions.logger; } - return (extraDefaults && extraDefaults.logger) || createConsoleLogger('warn'); + return (extraOptionDefs && extraOptionDefs.logger && extraOptionDefs.logger.default) || createConsoleLogger('warn'); } function readFlagsFromBootstrap(data) { diff --git a/src/messages.js b/src/messages.js index 21e3b22..5c5eb66 100644 --- a/src/messages.js +++ b/src/messages.js @@ -21,6 +21,10 @@ export const clientNotReady = function() { return 'LaunchDarkly client is not ready'; }; +export const eventCapacityExceeded = function() { + return 'Exceeded event queue capacity. Increase capacity to avoid dropping events.'; +}; + export const eventWithoutUser = function() { return 'Be sure to call `identify` in the LaunchDarkly client: https://docs.launchdarkly.com/docs/js-sdk-reference#section-analytics-events'; }; @@ -81,9 +85,9 @@ export const bootstrapInvalid = function() { export const deprecated = function(oldName, newName) { if (newName) { - return '[LaunchDarkly] "' + oldName + '" is deprecated, please use "' + newName + '"'; + return '"' + oldName + '" is deprecated, please use "' + newName + '"'; } - return '[LaunchDarkly] "' + oldName + '" is deprecated'; + return '"' + oldName + '" is deprecated'; }; export const httpErrorMessage = function(status, context, retryMessage) { @@ -124,6 +128,17 @@ export const streamError = function(err, streamReconnectDelay) { ); }; +export const unknownOption = name => 'Ignoring unknown config option "' + name + '"'; + +export const wrongOptionType = (name, expectedType, actualType) => + 'Config option "' + name + '" should be of type ' + expectedType + ', got ' + actualType + ', using default value'; + +export const wrongOptionTypeBoolean = (name, actualType) => + 'Config option "' + name + '" should be a boolean, got ' + actualType + ', converting to boolean'; + +export const optionBelowMinimum = (name, value, minimum) => + 'Config option "' + name + '" was set to ' + value + ', changing to minimum value of ' + minimum; + export const debugPolling = function(url) { return 'polling for feature flags at ' + url; }; diff --git a/typings.d.ts b/typings.d.ts index 370c66a..13cdbaa 100644 --- a/typings.d.ts +++ b/typings.d.ts @@ -185,6 +185,20 @@ declare module 'launchdarkly-js-sdk-common' { */ sendEventsOnlyForVariation?: boolean; + /** + * The capacity of the analytics events queue. + * + * The client buffers up to this many events in memory before flushing. If the capacity is exceeded + * before the queue is flushed, events will be discarded. Increasing the capacity means that events + * are less likely to be discarded, at the cost of consuming more memory. Note that in regular usage + * flag evaluations do not produce individual events, only summary counts, so you only need a large + * capacity if you are generating a large number of click, pageview, or identify events (or if you + * are using the event debugger). + * + * The default value is 100. + */ + eventCapacity?: number; + /** * The interval in between flushes of the analytics events queue, in milliseconds. * @@ -198,6 +212,8 @@ declare module 'launchdarkly-js-sdk-common' { * * When set to greater than zero, there is a 1 in `samplingInterval` chance that events will be * sent: for example, a value of 20 means that on average 1 in 20, or 5%, of all events will be sent. + * + * @deprecated This feature will be removed in a future version. */ samplingInterval?: number;