diff --git a/jest.config.ts b/jest.config.ts index 55818da3a..c4b08a5cb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -171,6 +171,24 @@ export default { ], }, }, + { + displayName: 'react', + testEnvironment: 'jsdom', + preset: 'ts-jest', + testMatch: ['/packages/react/test/**/*.spec.ts*'], + moduleNameMapper: { + '@openfeature/core': '/packages/shared/src', + '@openfeature/web-sdk': '/packages/client/src', + }, + transform: { + '^.+\\.tsx$': [ + 'ts-jest', + { + tsconfig: '/packages/react/test/tsconfig.json', + }, + ], + }, + }, ], // Use this configuration option to add custom reporters to Jest diff --git a/package-lock.json b/package-lock.json index 3ae7a70ff..3c70481ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,8 @@ ], "devDependencies": { "@rollup/plugin-typescript": "^11.1.6", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.2", "@types/jest": "^29.5.12", "@types/node": "^20.11.16", "@types/react": "^18.2.55", @@ -61,6 +63,12 @@ "node": ">=0.10.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", + "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", + "dev": true + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", @@ -659,6 +667,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", @@ -2067,6 +2087,139 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz", + "integrity": "sha512-CzqH0AFymEMG48CpzXFriYYkOjk6ZGPCLMhW9e9jg3KMCn5OfJecF8GtGW7yGfR/IgCe3SX8BSwjdzI6BBbZLw==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.3.2", + "@babel/runtime": "^7.9.2", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.15", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@jest/globals": ">= 28", + "@types/bun": "latest", + "@types/jest": ">= 28", + "jest": ">= 28", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "@jest/globals": { + "optional": true + }, + "@types/bun": { + "optional": true + }, + "@types/jest": { + "optional": true + }, + "jest": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "14.2.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.2.2.tgz", + "integrity": "sha512-SOUuM2ysCvjUWBXTNfQ/ztmnKDmqaiPV3SvoIuyxMUca45rbSWWAT/qB8CUs/JQ/ux/8JFs9DNdFQ3f6jH3crA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -2100,6 +2253,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2285,6 +2444,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "18.2.24", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", + "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", @@ -2732,6 +2900,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "dependencies": { + "deep-equal": "^2.0.5" + } + }, "node_modules/arr-diff": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", @@ -3302,15 +3479,16 @@ } }, "node_modules/call-bind": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", - "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", "dev": true, "dependencies": { + "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", - "set-function-length": "^1.2.0" + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" }, "engines": { "node": ">= 0.4" @@ -3699,6 +3877,12 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/cssom": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", @@ -3820,6 +4004,38 @@ } } }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3969,6 +4185,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -4110,6 +4332,18 @@ "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", "dev": true }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -4119,6 +4353,26 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-set-tostringtag": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", @@ -5871,6 +6125,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5931,6 +6194,22 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -6151,6 +6430,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", @@ -6230,6 +6521,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-shared-array-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", @@ -6305,6 +6608,18 @@ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "dev": true }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -6317,6 +6632,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -9876,6 +10207,15 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "dev": true }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", @@ -10045,6 +10385,15 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", @@ -10369,6 +10718,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -11025,6 +11390,20 @@ "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -11172,6 +11551,19 @@ "node": ">= 0.10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", @@ -11179,6 +11571,12 @@ "dev": true, "peer": true }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -11812,6 +12210,16 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dev": true, + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", @@ -12403,6 +12811,18 @@ "node": ">= 0.8" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", + "integrity": "sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==", + "dev": true, + "dependencies": { + "internal-slot": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -12538,6 +12958,18 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -13505,6 +13937,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/package.json b/package.json index aa03322ea..822a8ebb7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "description": "OpenFeature SDK for JavaScript", "scripts": { - "test": "jest --selectProjects=shared --selectProjects=server --selectProjects=client --silent", + "test": "jest --selectProjects=shared --selectProjects=server --selectProjects=client --selectProjects=react --silent", "e2e-server": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/server/e2e/features && jest --selectProjects=server-e2e --verbose", "e2e-client": "git submodule update --init --recursive && shx cp test-harness/features/evaluation.feature packages/client/e2e/features && jest --selectProjects=client-e2e --verbose", "e2e": "npm run e2e-server && npm run e2e-client", @@ -37,6 +37,8 @@ }, "devDependencies": { "@rollup/plugin-typescript": "^11.1.6", + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.2", "@types/jest": "^29.5.12", "@types/node": "^20.11.16", "@types/react": "^18.2.55", diff --git a/packages/react/src/provider/use-when-provider-ready.ts b/packages/react/src/provider/use-when-provider-ready.ts index 7d68ac728..7cbc2b0e4 100644 --- a/packages/react/src/provider/use-when-provider-ready.ts +++ b/packages/react/src/provider/use-when-provider-ready.ts @@ -18,12 +18,24 @@ export function useWhenProviderReady(options?: Options): boolean { const [, updateState] = useState(); const client = useOpenFeatureClient(); // highest priority > evaluation hook options > provider options > default options > lowest priority - const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options)}; + const defaultedOptions = { ...DEFAULT_OPTIONS, ...useProviderOptions(), ...normalizeOptions(options) }; + const updateStateRef = () => { + updateState({}); + }; useEffect(() => { - if (defaultedOptions.suspendUntilReady && client.providerStatus === ProviderStatus.NOT_READY) { - suspend(client, updateState, ProviderEvents.Ready); + if (client.providerStatus === ProviderStatus.NOT_READY) { + // re-render when provider is ready + client.addHandler(ProviderEvents.Ready, updateStateRef); + if (defaultedOptions.suspendUntilReady) { + // suspend and update when the provider is ready + suspend(client, updateState, ProviderEvents.Ready); + } } + return () => { + // cleanup the handler + client.removeHandler(ProviderEvents.Ready, updateStateRef); + }; }, []); return client.providerStatus === ProviderStatus.READY; diff --git a/packages/react/test/evaluation.spec.tsx b/packages/react/test/evaluation.spec.tsx new file mode 100644 index 000000000..15e7e7c6c --- /dev/null +++ b/packages/react/test/evaluation.spec.tsx @@ -0,0 +1,404 @@ +import { EvaluationContext, InMemoryProvider, OpenFeature, StandardResolutionReasons } from '@openfeature/web-sdk'; +import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup +import { act, render, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { + OpenFeatureProvider, + useBooleanFlagDetails, + useBooleanFlagValue, + useFlag, + useNumberFlagDetails, + useNumberFlagValue, + useObjectFlagDetails, + useObjectFlagValue, + useStringFlagDetails, + useStringFlagValue, +} from '../src/'; +import { TestingProvider } from './test.utils'; + +describe('evaluation', () => { + const EVALUATION = 'evaluation'; + const BOOL_FLAG_KEY = 'boolean-flag'; + const BOOL_FLAG_VARIANT = 'on'; + const BOOL_FLAG_VALUE = true; + const STRING_FLAG_KEY = 'string-flag'; + const STRING_FLAG_VARIANT = 'greeting'; + const STRING_FLAG_VALUE = 'hi'; + const NUMBER_FLAG_KEY = 'number-flag'; + const NUMBER_FLAG_VARIANT = '2^10'; + const NUMBER_FLAG_VALUE = 1024; + const OBJECT_FLAG_KEY = 'object-flag'; + const OBJECT_FLAG_VARIANT = 'template'; + const OBJECT_FLAG_VALUE = { factor: 'x1000' }; + const VARIANT_ATTR = 'data-variant'; + const REASON_ATTR = 'data-reason'; + const REASON_ATTR_VALUE = StandardResolutionReasons.STATIC; + const TYPE_ATTR = 'data-type'; + + const provider = new InMemoryProvider({ + [BOOL_FLAG_KEY]: { + disabled: false, + variants: { + [BOOL_FLAG_VARIANT]: BOOL_FLAG_VALUE, + off: false, + }, + defaultVariant: BOOL_FLAG_VARIANT, + }, + [STRING_FLAG_KEY]: { + disabled: false, + variants: { + [STRING_FLAG_VARIANT]: STRING_FLAG_VALUE, + parting: 'bye', + }, + defaultVariant: STRING_FLAG_VARIANT, + }, + [NUMBER_FLAG_KEY]: { + disabled: false, + variants: { + [NUMBER_FLAG_VARIANT]: NUMBER_FLAG_VALUE, + '2^1': 2, + }, + defaultVariant: NUMBER_FLAG_VARIANT, + }, + [OBJECT_FLAG_KEY]: { + disabled: false, + variants: { + [OBJECT_FLAG_VARIANT]: OBJECT_FLAG_VALUE, + empty: {}, + }, + defaultVariant: OBJECT_FLAG_VARIANT, + }, + }); + + OpenFeature.setProvider(EVALUATION, provider); + + describe('useFlag hook', () => { + function TestComponent() { + const { + value: booleanVal, + reason: boolReason, + variant: boolVariant, + type: booleanType, + } = useFlag(BOOL_FLAG_KEY, false); + + const { + value: stringVal, + reason: stringReason, + variant: stringVariant, + type: stringType, + } = useFlag(STRING_FLAG_KEY, 'default'); + + const { + value: numberVal, + reason: numberReason, + variant: numberVariant, + type: numberType, + } = useFlag(NUMBER_FLAG_KEY, 0); + + const { + value: objectVal, + reason: objectReason, + variant: objectVariant, + type: objectType, + } = useFlag(OBJECT_FLAG_KEY, {}); + + return ( + <> +
{`${booleanVal}`}
+
+ {stringVal} +
+
{`${numberVal}`}
+
+ {JSON.stringify(objectVal)} +
+ + ); + } + + it('should evaluate flags', () => { + render( + + + , + ); + + const boolElement = screen.queryByText(`${BOOL_FLAG_VALUE}`); + const stringElement = screen.queryByText(STRING_FLAG_VALUE); + const numberElement = screen.queryByText(`${NUMBER_FLAG_VALUE}`); + const objectElement = screen.queryByText(JSON.stringify(OBJECT_FLAG_VALUE)); + + expect(boolElement).toBeInTheDocument(); + expect(boolElement).toHaveAttribute(VARIANT_ATTR, BOOL_FLAG_VARIANT); + expect(boolElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + expect(boolElement).toHaveAttribute(TYPE_ATTR, 'boolean'); + + expect(stringElement).toBeInTheDocument(); + expect(stringElement).toHaveAttribute(VARIANT_ATTR, STRING_FLAG_VARIANT); + expect(stringElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + expect(stringElement).toHaveAttribute(TYPE_ATTR, 'string'); + + expect(numberElement).toBeInTheDocument(); + expect(numberElement).toHaveAttribute(VARIANT_ATTR, NUMBER_FLAG_VARIANT); + expect(numberElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + expect(numberElement).toHaveAttribute(TYPE_ATTR, 'number'); + + expect(objectElement).toBeInTheDocument(); + expect(objectElement).toHaveAttribute(VARIANT_ATTR, OBJECT_FLAG_VARIANT); + expect(objectElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + expect(objectElement).toHaveAttribute(TYPE_ATTR, 'object'); + }); + }); + + describe('useFlagValue hooks', () => { + function TestComponent() { + const booleanVal = useBooleanFlagValue(BOOL_FLAG_KEY, false); + const stringVal = useStringFlagValue(STRING_FLAG_KEY, 'default'); + const numberVal = useNumberFlagValue(NUMBER_FLAG_KEY, 0); + const objectVal = useObjectFlagValue(OBJECT_FLAG_KEY, {}); + return ( + <> +
{`${booleanVal}`}
+
{stringVal}
+
{numberVal}
+
{JSON.stringify(objectVal)}
+ + ); + } + + it('should evaluate flags', () => { + render( + + + , + ); + expect(screen.queryByText(STRING_FLAG_VALUE)).toBeInTheDocument(); + }); + }); + + describe('useFlagDetails hooks', () => { + function TestComponent() { + const booleanValDetails = useBooleanFlagDetails(BOOL_FLAG_KEY, false); + const stringValDetails = useStringFlagDetails(STRING_FLAG_KEY, 'default'); + const numberValDetails = useNumberFlagDetails(NUMBER_FLAG_KEY, 0); + const objectValDetails = useObjectFlagDetails(OBJECT_FLAG_KEY, {}); + return ( + <> +
{`${booleanValDetails.value}`}
+
+ {stringValDetails.value} +
+
{`${numberValDetails.value}`}
+
+ {JSON.stringify(objectValDetails.value)} +
+ + ); + } + + it('should evaluate flags', () => { + render( + + + , + ); + + const boolElement = screen.queryByText(`${BOOL_FLAG_VALUE}`); + const stringElement = screen.queryByText(STRING_FLAG_VALUE); + const numberElement = screen.queryByText(`${NUMBER_FLAG_VALUE}`); + const objectElement = screen.queryByText(JSON.stringify(OBJECT_FLAG_VALUE)); + + expect(boolElement).toBeInTheDocument(); + expect(boolElement).toHaveAttribute(VARIANT_ATTR, BOOL_FLAG_VARIANT); + expect(boolElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + + expect(stringElement).toBeInTheDocument(); + expect(stringElement).toHaveAttribute(VARIANT_ATTR, STRING_FLAG_VARIANT); + expect(stringElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + + expect(numberElement).toBeInTheDocument(); + expect(numberElement).toHaveAttribute(VARIANT_ATTR, NUMBER_FLAG_VARIANT); + expect(numberElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + + expect(objectElement).toBeInTheDocument(); + expect(objectElement).toHaveAttribute(VARIANT_ATTR, OBJECT_FLAG_VARIANT); + expect(objectElement).toHaveAttribute(REASON_ATTR, REASON_ATTR_VALUE); + }); + }); +}); + +describe('re-rending and suspense', () => { + /** + * artificial delay for various async operations for our provider, + * multiples of it are used in assertions as well + */ + const DELAY = 100; + + const SUSPENSE_ON = 'suspense'; + const SUSPENSE_OFF = 'suspense-off'; + const CONFIG_UPDATE = 'config-update'; + const SUSPENSE_FLAG_KEY = 'delayed-flag'; + const FLAG_VARIANT_A = 'greeting'; + const STATIC_FLAG_VALUE_A = 'hi'; + const FLAG_VARIANT_B = 'parting'; + const STATIC_FLAG_VALUE_B = 'bye'; + const TARGETED_FLAG_VALUE = 'aloha'; + const FALLBACK = 'fallback'; + const DEFAULT = 'default'; + const TARGETED_USER = 'bob@flags.com'; + const CONFIG = { + [SUSPENSE_FLAG_KEY]: { + disabled: false, + variants: { + [FLAG_VARIANT_A]: STATIC_FLAG_VALUE_A, + [FLAG_VARIANT_B]: STATIC_FLAG_VALUE_B, + both: TARGETED_FLAG_VALUE, + }, + defaultVariant: 'greeting', + contextEvaluator: (context: EvaluationContext) => { + if (context.user == 'bob@flags.com') { + return 'both'; + } + return 'greeting'; + }, + }, + }; + + const suspendingProvider = () => { + return new TestingProvider(CONFIG, DELAY); // delay init by 100ms + }; + + function TestComponent() { + const { value } = useFlag(SUSPENSE_FLAG_KEY, DEFAULT); + return ( + <> +
{value}
+ + ); + } + + describe('updateOnConfigurationChanged=true (default)', () => { + it('should re-render after flag config changes', async () => { + const provider = suspendingProvider(); + OpenFeature.setProvider(CONFIG_UPDATE, provider); + + render( + + {FALLBACK}}> + + + , + ); + + // first we should see the old value + await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), { timeout: DELAY * 2 }); + + // change our flag config + await act(async () => { + await provider.putConfiguration({ + [SUSPENSE_FLAG_KEY]: { + ...CONFIG[SUSPENSE_FLAG_KEY], + ...{ defaultVariant: FLAG_VARIANT_B, contextEvaluator: undefined }, + }, + }); + }); + + // eventually we should see the new value + await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_B)).toBeInTheDocument(), { timeout: DELAY * 2 }); + }); + }); + + describe('suspendUntilReady=true (default)', () => { + it('should suspend until ready and then render', async () => { + OpenFeature.setProvider(SUSPENSE_ON, suspendingProvider()); + + render( + + {FALLBACK}}> + + + , + ); + + // should see fallback initially + expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeNull(); + expect(screen.queryByText(FALLBACK)).toBeInTheDocument(); + // eventually we should see the value + await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), { timeout: DELAY * 2 }); + }); + }); + + describe('suspendWhileReconciling=true (default)', () => { + it('should suspend until reconciled and then render', async () => { + await OpenFeature.setContext(SUSPENSE_OFF, {}); + OpenFeature.setProvider(SUSPENSE_ON, suspendingProvider()); + + render( + // disable suspendUntilReady, we are only testing reconcile suspense. + + {FALLBACK}}> + + + , + ); + + // initially should be default, because suspendUntilReady={false} + expect(screen.queryByText(DEFAULT)).toBeInTheDocument(); + + // update the context without awaiting + act(() => { + OpenFeature.setContext(SUSPENSE_ON, { user: TARGETED_USER }); + }); + + // expect to see fallback while we are reconciling + await waitFor(() => expect(screen.queryByText(FALLBACK)).toBeInTheDocument(), { timeout: DELAY / 2 }); + + // make sure we updated after reconciling + await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), { + timeout: DELAY * 2, + }); + }); + }); + + describe('suspend=false', () => { + it('should not suspend until reconciled and then render', async () => { + await OpenFeature.setContext(SUSPENSE_OFF, {}); + OpenFeature.setProvider(SUSPENSE_OFF, suspendingProvider()); + + render( + // disable suspendUntilReady, we are only testing reconcile suspense. + + {FALLBACK}}> + + + , + ); + + // assert no suspense + expect(screen.queryByText(DEFAULT)).toBeInTheDocument(); + expect(screen.queryByText(FALLBACK)).toBeNull(); + + // expect to see static value after we are ready + await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), { timeout: DELAY * 2 }); + + // update the context without awaiting + act(() => { + OpenFeature.setContext(SUSPENSE_OFF, { user: TARGETED_USER }); + }); + + // expect to see static value until we reconcile + await waitFor(() => expect(screen.queryByText(STATIC_FLAG_VALUE_A)).toBeInTheDocument(), { timeout: DELAY / 2 }); + + // make sure we updated after reconciling + await waitFor(() => expect(screen.queryByText(TARGETED_FLAG_VALUE)).toBeInTheDocument(), { + timeout: DELAY * 2, + }); + }); + }); +}); diff --git a/packages/react/test/options.spec.ts b/packages/react/test/options.spec.ts new file mode 100644 index 000000000..e5f120a64 --- /dev/null +++ b/packages/react/test/options.spec.ts @@ -0,0 +1,47 @@ +import { normalizeOptions } from '../src/common/options'; + +describe('normalizeOptions', () => { + // we spread results from this function, so we never want to return null + describe('undefined options', () => { + it('should return empty object', () => { + const normalized = normalizeOptions(); + expect(normalized).toEqual({}); + }); + }); + + // we spread results from this function, so we want to remove anything but explicit booleans + describe('undefined removal', () => { + it('should remove undefined props and maintain boolean props', () => { + const normalized = normalizeOptions({ + suspendUntilReady: undefined, + suspendWhileReconciling: false, + updateOnConfigurationChanged: undefined, + updateOnContextChanged: true, + }); + expect(normalized).not.toHaveProperty('suspendUntilReady'); + expect(normalized).toHaveProperty('suspendWhileReconciling'); + expect(normalized.suspendWhileReconciling).toEqual(false); + expect(normalized).not.toHaveProperty('updateOnConfigurationChanged'); + expect(normalized).toHaveProperty('updateOnContextChanged'); + expect(normalized.updateOnContextChanged).toEqual(true); + }); + }); + + // we fallback the more specific suspense props (`ssuspendUntilReady` and `suspendWhileReconciling`) to `suspend` + describe('suspend fallback', () => { + it('should fallback to true suspend value', () => { + const normalized = normalizeOptions({ + suspend: true, + }); + expect(normalized.suspendUntilReady).toEqual(true); + expect(normalized.suspendWhileReconciling).toEqual(true); + }); + it('should fallback to false suspend value', () => { + const normalized = normalizeOptions({ + suspend: false, + }); + expect(normalized.suspendUntilReady).toEqual(false); + expect(normalized.suspendWhileReconciling).toEqual(false); + }); + }); +}); diff --git a/packages/react/test/provider.spec.tsx b/packages/react/test/provider.spec.tsx new file mode 100644 index 000000000..524272635 --- /dev/null +++ b/packages/react/test/provider.spec.tsx @@ -0,0 +1,132 @@ +import { EvaluationContext, OpenFeature } from '@openfeature/web-sdk'; +import '@testing-library/jest-dom'; // see: https://testing-library.com/docs/react-testing-library/setup +import { render, renderHook, screen, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { OpenFeatureProvider, useOpenFeatureClient, useWhenProviderReady } from '../src'; +import { TestingProvider } from './test.utils'; + +describe('provider', () => { + /** + * artificial delay for various async operations for our provider, + * multiples of it are used in assertions as well + */ + const DELAY = 100; + const SUSPENSE_ON = 'suspense'; + const SUSPENSE_OFF = 'suspense'; + const SUSPENSE_FLAG_KEY = 'delayed-flag'; + const STATIC_FLAG_VALUE = 'hi'; + const TARGETED_FLAG_VALUE = 'aloha'; + const FALLBACK = 'fallback'; + + const suspendingProvider = () => { + return new TestingProvider( + { + [SUSPENSE_FLAG_KEY]: { + disabled: false, + variants: { + greeting: STATIC_FLAG_VALUE, + parting: 'bye', + both: TARGETED_FLAG_VALUE, + }, + defaultVariant: 'greeting', + contextEvaluator: (context: EvaluationContext) => { + if (context.user == 'bob@flags.com') { + return 'both'; + } + return 'greeting'; + }, + }, + }, + DELAY, + ); // delay init by 100ms + }; + + describe('useOpenFeatureClient', () => { + const DOMAIN = 'useOpenFeatureClient'; + + describe('client specified', () => { + it('should return client from provider', () => { + const client = OpenFeature.getClient(DOMAIN); + + const wrapper = ({ children }: Parameters[0]) => ( + {children} + ); + + const { result } = renderHook(() => useOpenFeatureClient(), { wrapper }); + + expect(result.current).toEqual(client); + }); + }); + + describe('domain specified', () => { + it('should return client with domain', () => { + const wrapper = ({ children }: Parameters[0]) => ( + {children} + ); + + const { result } = renderHook(() => useOpenFeatureClient(), { wrapper }); + + expect(result.current.metadata.domain).toEqual(DOMAIN); + }); + }); + }); + + describe('useWhenProviderReady', () => { + describe('suspendUntilReady=true (default)', () => { + function TestComponent() { + const isReady = useWhenProviderReady(); + return ( + <> +
{isReady ? '👍' : '👎'}
+ + ); + } + + it('should suspend until ready and then return provider status', async () => { + OpenFeature.setProvider(SUSPENSE_ON, suspendingProvider()); + + render( + + {FALLBACK}}> + + + , + ); + + // should see fallback initially + expect(screen.queryByText('👎')).not.toBeVisible(); + expect(screen.queryByText(FALLBACK)).toBeInTheDocument(); + // eventually we should the value + await waitFor(() => expect(screen.queryByText('👍')).toBeVisible(), { timeout: DELAY * 2 }); + }); + }); + + describe('suspendUntilReady=false', () => { + function TestComponent() { + const isReady = useWhenProviderReady({ suspendUntilReady: false }); + return ( + <> +
{isReady ? '👍' : '👎'}
+ + ); + } + + it('should not suspend, should return provider status', async () => { + OpenFeature.setProvider(SUSPENSE_OFF, suspendingProvider()); + + render( + + {FALLBACK}}> + + + , + ); + + // should see falsy value initially + expect(screen.queryByText('👎')).toBeInTheDocument(); + // eventually we should the value + await waitFor(() => expect(screen.queryByText('👍')).toBeInTheDocument(), { timeout: DELAY * 2 }); + }); + }); + }); +}); diff --git a/packages/react/test/test.utils.ts b/packages/react/test/test.utils.ts new file mode 100644 index 000000000..11b82d256 --- /dev/null +++ b/packages/react/test/test.utils.ts @@ -0,0 +1,21 @@ +import { EvaluationContext, InMemoryProvider } from '@openfeature/web-sdk'; + +export class TestingProvider extends InMemoryProvider { + constructor( + flagConfiguration: ConstructorParameters[0], + private delay: number, + ) { + super(flagConfiguration); + } + + // artificially delay our init (delaying PROVIDER_READY event) + async initialize(context?: EvaluationContext | undefined): Promise { + await new Promise((resolve) => setTimeout(resolve, this.delay)); + return super.initialize(context); + } + + // artificially delay context changes + async onContextChange(): Promise { + await new Promise((resolve) => setTimeout(resolve, this.delay)); + } + } \ No newline at end of file