This is page 7 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/flag-evaluation/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { sha256 } from "js-sha256";
2 |
3 | /**
4 | * Represents a filter class with a specific type property.
5 | *
6 | * This type is intended to define the structure for objects
7 | * that classify or categorize based on a particular filter type.
8 | *
9 | * Properties:
10 | * - type: Specifies the classification type as a string.
11 | */
12 | export type FilterClass = {
13 | type: string;
14 | };
15 |
16 | /**
17 | * Represents a group of filters that can be combined with a logical operator.
18 | *
19 | * @template T The type of filter class that defines the criteria within the filter group.
20 | * @property type The fixed type indicator for this filter structure, always "group".
21 | * @property operator The logical operator used to combine the filters in the group. It can be either "and" (all conditions must pass) or "or" (at least one condition must pass).
22 | * @property filters An array of filter trees containing individual filters or nested groups of filters.
23 | */
24 | export type FilterGroup<T extends FilterClass> = {
25 | type: "group";
26 | operator: "and" | "or";
27 | filters: FilterTree<T>[];
28 | };
29 |
30 | /**
31 | * Represents a filter negation structure for use within filtering systems.
32 | *
33 | * A `FilterNegation` is used to encapsulate a negation operation,
34 | * which negates the conditions defined in the provided `filter`.
35 | *
36 | * @template T - A generic type that extends FilterClass, indicating the type of the filter.
37 | * @property type - Specifies the type of this filter operation as "negation".
38 | * @property filter - A `FilterTree` structure of type `T` that defines the filter conditions to be negated.
39 | */
40 | export type FilterNegation<T extends FilterClass> = {
41 | type: "negation";
42 | filter: FilterTree<T>;
43 | };
44 |
45 | /**
46 | * Represents a tree structure for filters that can be composed of filter groups,
47 | * filter negations, or individual filter instances of a specified type.
48 | *
49 | * @template T - A type that extends the `FilterClass`.
50 | */
51 | export type FilterTree<T extends FilterClass> =
52 | | FilterGroup<T>
53 | | FilterNegation<T>
54 | | T;
55 |
56 | /**
57 | * Represents a set of predefined operators that can be used to filter a specific context.
58 | * These operators can express various conditions, including equality checks, comparison,
59 | * set membership, and boolean evaluations.
60 | *
61 | * Possible values:
62 | * - "IS": Specifies exact match.
63 | * - "IS_NOT": Specifies a negation of exact match.
64 | * - "ANY_OF": Checks if a value is present in a set of specified values.
65 | * - "NOT_ANY_OF": Checks if a value is not present in a set of specified values.
66 | * - "CONTAINS": Verifies if a value contains a specific substring or element.
67 | * - "NOT_CONTAINS": Verifies if a value does not contain a specific substring or element.
68 | * - "GT": Greater than comparison.
69 | * - "LT": Less than comparison.
70 | * - "AFTER": Compares if a value is after a specified point (e.g., time, rank).
71 | * - "BEFORE": Compares if a value is before a specified point (e.g., time, rank).
72 | * - "SET": Checks if a value is set or exists.
73 | * - "NOT_SET": Checks if a value is not set or does not exist.
74 | * - "IS_TRUE": Checks if a boolean value is true.
75 | * - "IS_FALSE": Checks if a boolean value is false.
76 | */
77 | type ContextFilterOperator =
78 | | "IS"
79 | | "IS_NOT"
80 | | "ANY_OF"
81 | | "NOT_ANY_OF"
82 | | "CONTAINS"
83 | | "NOT_CONTAINS"
84 | | "GT"
85 | | "LT"
86 | | "AFTER"
87 | | "BEFORE"
88 | | "DATE_AFTER"
89 | | "DATE_BEFORE"
90 | | "SET"
91 | | "NOT_SET"
92 | | "IS_TRUE"
93 | | "IS_FALSE";
94 |
95 | /**
96 | * Represents a filter configuration used to filter data based on specific context.
97 | *
98 | * This interface defines the structure of a context filter, containing a field,
99 | * an operator, and optional values to control the filtering behavior.
100 | *
101 | * The `type` property must always have the value "context" to classify filters
102 | * of this type.
103 | *
104 | * The `field` property specifies the name of the context field to filter.
105 | *
106 | * The `operator` property defines the filtering operation to perform on the
107 | * specified field (e.g., equals, contains, etc.).
108 | *
109 | * The optional `values` property is an array of strings that lists the values
110 | * to be used in conjunction with the operator for filtering.
111 | *
112 | * This interface is typically utilized in contexts where data needs to be
113 | * dynamically filtered based on specific criteria derived from contextual
114 | * attributes.
115 | */
116 | export interface ContextFilter {
117 | type: "context";
118 | field: string;
119 | operator: ContextFilterOperator;
120 | values?: string[];
121 | valueSet?: Set<string>;
122 | }
123 |
124 | /**
125 | * Represents a filter configuration to enable percentage-based rollout of a flag or functionality.
126 | *
127 | * This type defines the necessary parameters to control access to a flag
128 | * by evaluating a specific attribute and applying it against a defined percentage threshold.
129 | *
130 | * Properties:
131 | * - `type` - Indicates the type of the filter. For this filter type, it will always be "rolloutPercentage".
132 | * - `key` - A unique key or identifier that distinguishes this rollout filter.
133 | * - `partialRolloutAttribute` - Specifies the attribute used to evaluate eligibility for the rollout.
134 | * - `partialRolloutThreshold` - A numeric value representing the upper-bound threshold (0-100) for the percentage-based rollout.
135 | */
136 | export type PercentageRolloutFilter = {
137 | type: "rolloutPercentage";
138 | key: string;
139 | partialRolloutAttribute: string;
140 | partialRolloutThreshold: number;
141 | };
142 |
143 | /**
144 | * Represents a constant filter configuration.
145 | *
146 | * The ConstantFilter type is used to define a filter configuration with a fixed,
147 | * immutable value. It always evaluates to the specified boolean `value`.
148 | *
149 | * @property {string} type - Indicates the type of filter, which is always "constant".
150 | * @property {boolean} value - The fixed boolean value for the filter.
151 | */
152 | export type ConstantFilter = {
153 | type: "constant";
154 | value: boolean;
155 | };
156 |
157 | /**
158 | * A composite type for representing a rule-based filter system.
159 | *
160 | * This type is constructed using a `FilterTree` structure that consists of
161 | * nested filters of the following types:
162 | * - `ContextFilter`: A filter that evaluates based on specified context criteria.
163 | * - `PercentageRolloutFilter`: A filter that performs a percentage-based rollout.
164 | * - `ConstantFilter`: A filter that evaluates based on fixed conditions or constants.
165 | *
166 | * `RuleFilter` is typically used in scenarios where a hierarchical filtering mechanism
167 | * is needed to determine outcomes based on multiple layered conditions.
168 | */
169 | export type RuleFilter = FilterTree<
170 | ContextFilter | PercentageRolloutFilter | ConstantFilter
171 | >;
172 |
173 | /**
174 | * Represents a value that can be used in a rule configuration.
175 | *
176 | * RuleValue can take on different types, allowing flexibility based on the
177 | * specific rule's requirements. This can include:
178 | * - A boolean value: to represent true/false conditions.
179 | * - A string: typically used for textual or keyword-based rules.
180 | * - A number: for numerical rules or thresholds.
181 | * - An object: for more complex rule definitions or configurations.
182 | *
183 | * This type is useful for accommodating various rule structures in applications
184 | * that work with dynamic or user-defined regulations.
185 | */
186 | type RuleValue = boolean | string | number | object;
187 |
188 | /**
189 | * Represents a rule that defines a filtering criterion and an associated value.
190 | *
191 | * @template T - Specifies the type of the associated value that extends RuleValue.
192 | * @property {RuleFilter} filter - The filtering criterion used by the rule.
193 | * @property {T} value - The value associated with the rule.
194 | */
195 | export interface Rule<T extends RuleValue> {
196 | filter: RuleFilter;
197 | value: T;
198 | }
199 |
200 | /**
201 | * Flattens a nested JSON object into a single-level object, with keys indicating the nesting levels.
202 | * Keys in the resulting object are represented in a dot notation to reflect the nesting structure of the original data.
203 | *
204 | * @param {object} data - The nested JSON object to be flattened.
205 | * @return {Record<string, string>} A flattened JSON object with "stringified" keys and values.
206 | */
207 | export function flattenJSON(data: object): Record<string, string> {
208 | const result: Record<string, string> = {};
209 |
210 | if (Object.keys(data).length === 0) {
211 | return result;
212 | }
213 |
214 | function recurse(value: any, prop: string) {
215 | if (value === undefined) {
216 | return;
217 | }
218 |
219 | if (value === null) {
220 | result[prop] = "";
221 | } else if (typeof value !== "object") {
222 | result[prop] = String(value);
223 | } else if (Array.isArray(value)) {
224 | if (value.length === 0) {
225 | result[prop] = "";
226 | }
227 |
228 | for (let i = 0; i < value.length; i++) {
229 | recurse(value[i], prop ? prop + "." + i : "" + i);
230 | }
231 | } else {
232 | let isEmpty = true;
233 |
234 | for (const p in value) {
235 | isEmpty = false;
236 | recurse(value[p], prop ? prop + "." + p : p);
237 | }
238 |
239 | if (isEmpty) {
240 | result[prop] = "";
241 | }
242 | }
243 | }
244 |
245 | recurse(data, "");
246 | return result;
247 | }
248 |
249 | /**
250 | * Converts a flattened JSON object with dot-separated keys into a nested JSON object.
251 | *
252 | * @param {Record<string, any>} data - The flattened JSON object where keys are dot-separated representing nested levels.
253 | * @return {Record<string, any>} The unflattened JSON object with nested structure restored.
254 | */
255 | export function unflattenJSON(data: Record<string, any>): Record<string, any> {
256 | const result: Record<string, any> = {};
257 |
258 | for (const i in data) {
259 | const keys = i.split(".");
260 | keys.reduce((acc, key, index) => {
261 | if (index === keys.length - 1) {
262 | if (typeof acc === "object") {
263 | acc[key] = data[i];
264 | }
265 | } else if (!acc[key]) {
266 | acc[key] = {};
267 | }
268 |
269 | return acc[key];
270 | }, result);
271 | }
272 |
273 | return result;
274 | }
275 |
276 | /**
277 | * Generates a hashed integer based on the input string. The method extracts 20 bits from the hash,
278 | * scales it to a range between 0 and 100000, and returns the resultant integer.
279 | *
280 | * @param {string} hashInput - The input string used to generate the hash.
281 | * @return {number} A number between 0 and 100000 derived from the hash of the input string.
282 | */
283 | export function hashInt(hashInput: string): number {
284 | // 1. hash the key and the partial rollout attribute
285 | // 2. take 20 bits from the hash and divide by 2^20 - 1 to get a number between 0 and 1
286 | // 3. multiply by 100000 to get a number between 0 and 100000 and compare it to the threshold
287 | //
288 | // we only need 20 bits to get to 100000 because 2^20 is 1048576
289 | const value =
290 | new DataView(sha256.create().update(hashInput).arrayBuffer()).getUint32(
291 | 0,
292 | true,
293 | ) & 0xfffff;
294 |
295 | return Math.floor((value / 0xfffff) * 100000);
296 | }
297 |
298 | /**
299 | * Evaluates a field value against a specified operator and comparison values.
300 | *
301 | * @param {string} fieldValue - The value to be evaluated.
302 | * @param {ContextFilterOperator} operator - The operator used for the evaluation (e.g., "CONTAINS", "GT").
303 | * @param {string[]} values - An array of comparison values for evaluation.
304 | * @return {boolean} The result of the evaluation based on the operator and comparison values.
305 | */
306 | export function evaluate(
307 | fieldValue: string,
308 | operator: ContextFilterOperator,
309 | values: string[],
310 | valueSet?: Set<string>,
311 | ): boolean {
312 | const value = values[0];
313 |
314 | switch (operator) {
315 | case "CONTAINS":
316 | return fieldValue.toLowerCase().includes(value.toLowerCase());
317 | case "NOT_CONTAINS":
318 | return !fieldValue.toLowerCase().includes(value.toLowerCase());
319 | case "GT":
320 | if (isNaN(Number(fieldValue)) || isNaN(Number(value))) {
321 | // TODO: return error instead? used logger previously
322 | console.error(
323 | `GT operator requires numeric values: ${fieldValue}, ${value}`,
324 | );
325 | return false;
326 | }
327 | return Number(fieldValue) > Number(value);
328 | case "LT":
329 | if (isNaN(Number(fieldValue)) || isNaN(Number(value))) {
330 | console.error(
331 | `LT operator requires numeric values: ${fieldValue}, ${value}`,
332 | );
333 | return false;
334 | }
335 | return Number(fieldValue) < Number(value);
336 | case "AFTER":
337 | case "BEFORE": {
338 | // more/less than `value` days ago
339 | const daysAgo = new Date();
340 | daysAgo.setDate(daysAgo.getDate() - Number(value));
341 | const fieldValueDate = new Date(fieldValue).getTime();
342 |
343 | return operator === "AFTER"
344 | ? fieldValueDate > daysAgo.getTime()
345 | : fieldValueDate < daysAgo.getTime();
346 | }
347 | case "DATE_AFTER":
348 | case "DATE_BEFORE": {
349 | const fieldValueDate = new Date(fieldValue).getTime();
350 | const valueDate = new Date(value).getTime();
351 | if (isNaN(fieldValueDate) || isNaN(valueDate)) {
352 | console.error(
353 | `${operator} operator requires valid date values: ${fieldValue}, ${value}`,
354 | );
355 | return false;
356 | }
357 | return operator === "DATE_AFTER"
358 | ? fieldValueDate >= valueDate
359 | : fieldValueDate <= valueDate;
360 | }
361 | case "SET":
362 | return fieldValue !== "";
363 | case "NOT_SET":
364 | return fieldValue === "";
365 | case "IS":
366 | return fieldValue === value;
367 | case "IS_NOT":
368 | return fieldValue !== value;
369 | case "ANY_OF":
370 | return valueSet ? valueSet.has(fieldValue) : values.includes(fieldValue);
371 | case "NOT_ANY_OF":
372 | return valueSet
373 | ? !valueSet.has(fieldValue)
374 | : !values.includes(fieldValue);
375 | case "IS_TRUE":
376 | return fieldValue == "true";
377 | case "IS_FALSE":
378 | return fieldValue == "false";
379 | default:
380 | console.error(`unknown operator: ${operator}`);
381 | return false;
382 | }
383 | }
384 |
385 | function evaluateRecursively(
386 | filter: RuleFilter,
387 | context: Record<string, string>,
388 | missingContextFieldsSet: Set<string>,
389 | ): boolean {
390 | switch (filter.type) {
391 | case "constant":
392 | return filter.value;
393 | case "context":
394 | if (
395 | !(filter.field in context) &&
396 | filter.operator !== "SET" &&
397 | filter.operator !== "NOT_SET"
398 | ) {
399 | missingContextFieldsSet.add(filter.field);
400 | return false;
401 | }
402 |
403 | return evaluate(
404 | context[filter.field] ?? "",
405 | filter.operator,
406 | filter.values || [],
407 | filter.valueSet,
408 | );
409 | case "rolloutPercentage": {
410 | if (!(filter.partialRolloutAttribute in context)) {
411 | missingContextFieldsSet.add(filter.partialRolloutAttribute);
412 | return false;
413 | }
414 |
415 | const hashVal = hashInt(
416 | `${filter.key}.${context[filter.partialRolloutAttribute]}`,
417 | );
418 |
419 | return hashVal < filter.partialRolloutThreshold;
420 | }
421 | case "group":
422 | return filter.filters.reduce((acc, current) => {
423 | if (filter.operator === "and") {
424 | return (
425 | acc &&
426 | evaluateRecursively(current, context, missingContextFieldsSet)
427 | );
428 | }
429 | return (
430 | acc || evaluateRecursively(current, context, missingContextFieldsSet)
431 | );
432 | }, filter.operator === "and");
433 | case "negation":
434 | return !evaluateRecursively(
435 | filter.filter,
436 | context,
437 | missingContextFieldsSet,
438 | );
439 | default:
440 | return false;
441 | }
442 | }
443 |
444 | /**
445 | * Represents the parameters required for evaluating rules against a specific flag in a given context.
446 | *
447 | * @template T - The type of the rule value used in evaluation.
448 | *
449 | * @property {string} flagKey - The key that identifies the specific flag to be evaluated.
450 | * @property {Rule<T>[]} rules - An array of rules used for evaluation.
451 | * @property {Record<string, unknown>} context - The contextual data used during the evaluation process.
452 | */
453 | export interface EvaluationParams<T extends RuleValue> {
454 | flagKey: string;
455 | rules: Rule<T>[];
456 | context: Record<string, unknown>;
457 | }
458 |
459 | /**
460 | * Represents the result of an evaluation process for a specific flag and its associated rules.
461 | *
462 | * @template T - The type of the rule value being evaluated.
463 | *
464 | * @property {string} flagKey - The unique key identifying the flag being evaluated.
465 | * @property {T | undefined} value - The resolved value of the flag, if the evaluation is successful.
466 | * @property {Record<string, any>} context - The contextual information used during the evaluation process.
467 | * @property {boolean[]} ruleEvaluationResults - Array indicating the success or failure of each rule evaluated.
468 | * @property {string} [reason] - Optional field providing additional explanation regarding the evaluation result.
469 | * @property {string[]} [missingContextFields] - Optional array of context fields that were required but not provided during the evaluation.
470 | */
471 | export interface EvaluationResult<T extends RuleValue> {
472 | flagKey: string;
473 | value: T | undefined;
474 | context: Record<string, any>;
475 | ruleEvaluationResults: boolean[];
476 | reason?: string;
477 | missingContextFields?: string[];
478 | }
479 |
480 | export function evaluateFlagRules<T extends RuleValue>({
481 | context,
482 | flagKey,
483 | rules,
484 | }: EvaluationParams<T>): EvaluationResult<T> {
485 | const flatContext = flattenJSON(context);
486 | const missingContextFieldsSet = new Set<string>();
487 |
488 | const ruleEvaluationResults = rules.map((rule) =>
489 | evaluateRecursively(rule.filter, flatContext, missingContextFieldsSet),
490 | );
491 |
492 | const missingContextFields = Array.from(missingContextFieldsSet);
493 |
494 | const firstMatchedRuleIndex = ruleEvaluationResults.findIndex(Boolean);
495 | const firstMatchedRule =
496 | firstMatchedRuleIndex > -1 ? rules[firstMatchedRuleIndex] : undefined;
497 | return {
498 | value: firstMatchedRule?.value,
499 | flagKey,
500 | context: flatContext,
501 | ruleEvaluationResults,
502 | reason:
503 | firstMatchedRuleIndex > -1
504 | ? `rule #${firstMatchedRuleIndex} matched`
505 | : "no matched rules",
506 | missingContextFields,
507 | };
508 | }
509 |
510 | export function newEvaluator<T extends RuleValue>(rules: Rule<T>[]) {
511 | function translateRule(rule: RuleFilter): RuleFilter {
512 | if (rule.type === "group") {
513 | return {
514 | ...rule,
515 | filters: rule.filters.map(translateRule),
516 | };
517 | }
518 |
519 | if (
520 | rule.type === "context" &&
521 | (rule.operator === "ANY_OF" || rule.operator === "NOT_ANY_OF")
522 | ) {
523 | return {
524 | ...rule,
525 | valueSet: new Set(rule.values ?? []),
526 | };
527 | }
528 |
529 | return { ...rule };
530 | }
531 |
532 | const translatedRules = rules.map((rule) => {
533 | const { filter } = rule;
534 | const translatedFilter = translateRule(filter);
535 |
536 | return {
537 | ...rule,
538 | filter: translatedFilter,
539 | };
540 | });
541 |
542 | return function evaluateOptimized(
543 | context: Record<string, unknown>,
544 | flagKey: string,
545 | ) {
546 | return evaluateFlagRules({
547 | context,
548 | flagKey,
549 | rules: translatedRules,
550 | });
551 | };
552 | }
553 |
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { deepEqual } from "fast-equals";
2 |
3 | import {
4 | AutoFeedback,
5 | Feedback,
6 | feedback,
7 | FeedbackOptions,
8 | RequestFeedbackData,
9 | RequestFeedbackOptions,
10 | } from "./feedback/feedback";
11 | import * as feedbackLib from "./feedback/ui";
12 | import {
13 | CheckEvent,
14 | FallbackFlagOverride,
15 | FlagsClient,
16 | RawFlags,
17 | } from "./flag/flags";
18 | import { ToolbarPosition } from "./ui/types";
19 | import {
20 | API_BASE_URL,
21 | APP_BASE_URL,
22 | IS_SERVER,
23 | SSE_REALTIME_BASE_URL,
24 | } from "./config";
25 | import { ReflagContext, ReflagDeprecatedContext } from "./context";
26 | import { HookArgs, HooksManager, State } from "./hooksManager";
27 | import { HttpClient } from "./httpClient";
28 | import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger";
29 | import { showToolbarToggle } from "./toolbar";
30 |
31 | const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
32 | const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas
33 |
34 | /**
35 | * (Internal) User context.
36 | *
37 | * @internal
38 | */
39 | export type User = {
40 | /**
41 | * Identifier of the user.
42 | */
43 | userId: string;
44 |
45 | /**
46 | * User attributes.
47 | */
48 | attributes?: {
49 | /**
50 | * Name of the user.
51 | */
52 | name?: string;
53 |
54 | /**
55 | * Email of the user.
56 | */
57 | email?: string;
58 |
59 | /**
60 | * Avatar URL of the user.
61 | */
62 | avatar?: string;
63 |
64 | /**
65 | * Custom attributes of the user.
66 | */
67 | [key: string]: any;
68 | };
69 |
70 | /**
71 | * Custom context of the user.
72 | */
73 | context?: PayloadContext;
74 | };
75 |
76 | /**
77 | * (Internal) Company context.
78 | *
79 | * @internal
80 | */
81 | export type Company = {
82 | /**
83 | * User identifier.
84 | */
85 | userId: string;
86 |
87 | /**
88 | * Company identifier.
89 | */
90 | companyId: string;
91 |
92 | /**
93 | * Company attributes.
94 | */
95 | attributes?: {
96 | /**
97 | * Name of the company.
98 | */
99 | name?: string;
100 |
101 | /**
102 | * Custom attributes of the company.
103 | */
104 | [key: string]: any;
105 | };
106 |
107 | context?: PayloadContext;
108 | };
109 |
110 | /**
111 | * Tracked event.
112 | */
113 | export type TrackedEvent = {
114 | /**
115 | * Event name.
116 | */
117 | event: string;
118 |
119 | /**
120 | * User identifier.
121 | */
122 | userId: string;
123 |
124 | /**
125 | * Company identifier.
126 | */
127 | companyId?: string;
128 |
129 | /**
130 | * Event attributes.
131 | */
132 | attributes?: Record<string, any>;
133 |
134 | /**
135 | * Custom context of the event.
136 | */
137 | context?: PayloadContext;
138 | };
139 |
140 | /**
141 | * (Internal) Custom context of the event.
142 | *
143 | * @internal
144 | */
145 | export type PayloadContext = {
146 | /**
147 | * Whether the company and user associated with the event are active.
148 | */
149 | active?: boolean;
150 | };
151 |
152 | /**
153 | * ReflagClient configuration.
154 | */
155 | export interface Config {
156 | /**
157 | * Base URL of Reflag servers.
158 | */
159 | apiBaseUrl: string;
160 |
161 | /**
162 | * Base URL of the Reflag web app.
163 | */
164 | appBaseUrl: string;
165 |
166 | /**
167 | * Base URL of Reflag servers for SSE connections used by AutoFeedback.
168 | */
169 | sseBaseUrl: string;
170 |
171 | /**
172 | * Whether to enable tracking.
173 | */
174 | enableTracking: boolean;
175 |
176 | /**
177 | * Whether to enable offline mode.
178 | */
179 | offline: boolean;
180 |
181 | /**
182 | * Whether the client is bootstrapped.
183 | */
184 | bootstrapped: boolean;
185 | }
186 |
187 | /**
188 | * Toolbar options.
189 | */
190 | export type ToolbarOptions =
191 | | boolean
192 | | {
193 | show?: boolean;
194 | position?: ToolbarPosition;
195 | };
196 |
197 | /**
198 | * Flag definitions.
199 | */
200 | export type FlagDefinitions = Readonly<Array<string>>;
201 |
202 | /**
203 | * ReflagClient initialization options.
204 | */
205 | export type InitOptions = ReflagDeprecatedContext & {
206 | /**
207 | * Publishable key for authentication
208 | */
209 | publishableKey: string;
210 |
211 | /**
212 | * You can provide a logger to see the logs of the network calls.
213 | * This is undefined by default.
214 | * For debugging purposes you can just set the browser console to this property:
215 | * ```javascript
216 | * options.logger = window.console;
217 | * ```
218 | */
219 | logger?: Logger;
220 |
221 | /**
222 | * Base URL of Reflag servers. You can override this to use your mocked server.
223 | */
224 | apiBaseUrl?: string;
225 |
226 | /**
227 | * Base URL of the Reflag web app. Links open ín this app by default.
228 | */
229 | appBaseUrl?: string;
230 |
231 | /**
232 | * Whether to enable offline mode. Defaults to `false`.
233 | */
234 | offline?: boolean;
235 |
236 | /**
237 | * Flag keys for which `isEnabled` should fallback to true
238 | * if SDK fails to fetch flags from Reflag servers. If a record
239 | * is supplied instead of array, the values of each key represent the
240 | * configuration values and `isEnabled` is assume `true`.
241 | */
242 | fallbackFlags?: string[] | Record<string, FallbackFlagOverride>;
243 |
244 | /**
245 | * Timeout in milliseconds when fetching flags
246 | */
247 | timeoutMs?: number;
248 |
249 | /**
250 | * If set to true stale flags will be returned while refetching flags
251 | */
252 | staleWhileRevalidate?: boolean;
253 |
254 | /**
255 | * If set, flags will be cached between page loads for this duration
256 | */
257 | expireTimeMs?: number;
258 |
259 | /**
260 | * Stale flags will be returned if staleWhileRevalidate is true if no new flags can be fetched
261 | */
262 | staleTimeMs?: number;
263 |
264 | /**
265 | * When proxying requests, you may want to include credentials like cookies
266 | * so you can authorize the request in the proxy.
267 | * This option controls the `credentials` option of the fetch API.
268 | */
269 | credentials?: "include" | "same-origin" | "omit";
270 |
271 | /**
272 | * Base URL of Reflag servers for SSE connections used by AutoFeedback.
273 | */
274 | sseBaseUrl?: string;
275 |
276 | /**
277 | * AutoFeedback specific configuration
278 | */
279 | feedback?: FeedbackOptions;
280 |
281 | /**
282 | * Version of the SDK
283 | */
284 | sdkVersion?: string;
285 |
286 | /**
287 | * Whether to enable tracking. Defaults to `true`.
288 | */
289 | enableTracking?: boolean;
290 |
291 | /**
292 | * Toolbar configuration
293 | */
294 | toolbar?: ToolbarOptions;
295 |
296 | /**
297 | * Pre-fetched flags to be used instead of fetching them from the server.
298 | */
299 | bootstrappedFlags?: RawFlags;
300 | };
301 |
302 | const defaultConfig: Config = {
303 | apiBaseUrl: API_BASE_URL,
304 | appBaseUrl: APP_BASE_URL,
305 | sseBaseUrl: SSE_REALTIME_BASE_URL,
306 | enableTracking: true,
307 | offline: false,
308 | bootstrapped: false,
309 | };
310 |
311 | /**
312 | * A remotely managed configuration value for a flag.
313 | */
314 | export type FlagRemoteConfig =
315 | | {
316 | /**
317 | * The key of the matched configuration value.
318 | */
319 | key: string;
320 |
321 | /**
322 | * The optional user-supplied payload data.
323 | */
324 | payload: any;
325 | }
326 | | { key: undefined; payload: undefined };
327 |
328 | /**
329 | * Represents a flag.
330 | */
331 | export interface Flag {
332 | /**
333 | * Result of flag flag evaluation.
334 | * Note: Does not take local overrides into account.
335 | */
336 | isEnabled: boolean;
337 |
338 | /*
339 | * Optional user-defined configuration.
340 | */
341 | config: FlagRemoteConfig;
342 |
343 | /**
344 | * Function to send analytics events for this flag.
345 | */
346 | track: () => Promise<Response | undefined>;
347 |
348 | /**
349 | * Function to request feedback for this flag.
350 | */
351 | requestFeedback: (
352 | options: Omit<RequestFeedbackData, "flagKey" | "featureId">,
353 | ) => void;
354 |
355 | /**
356 | * The current override status of isEnabled for the flag.
357 | */
358 | isEnabledOverride: boolean | null;
359 |
360 | /**
361 | * Set the override status for isEnabled for the flag.
362 | * Set to `null` to remove the override.
363 | */
364 | setIsEnabledOverride(isEnabled: boolean | null): void;
365 | }
366 |
367 | function shouldShowToolbar(opts: InitOptions) {
368 | const toolbarOpts = opts.toolbar;
369 | if (typeof window === "undefined") return false;
370 | if (typeof toolbarOpts === "boolean") return toolbarOpts;
371 | if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show;
372 | return window.location.hostname === "localhost";
373 | }
374 |
375 | /**
376 | * ReflagClient lets you interact with the Reflag API.
377 | */
378 | export class ReflagClient {
379 | private state: State = "idle";
380 | private readonly publishableKey: string;
381 | private context: ReflagContext;
382 | private config: Config;
383 | private requestFeedbackOptions: Partial<RequestFeedbackOptions>;
384 | private readonly httpClient: HttpClient;
385 |
386 | private readonly autoFeedback: AutoFeedback | undefined;
387 | private autoFeedbackInit: Promise<void> | undefined;
388 | private readonly flagsClient: FlagsClient;
389 |
390 | public readonly logger: Logger;
391 |
392 | private readonly hooks: HooksManager;
393 |
394 | /**
395 | * Create a new ReflagClient instance.
396 | */
397 | constructor(opts: InitOptions) {
398 | this.publishableKey = opts.publishableKey;
399 | this.logger =
400 | opts?.logger ?? loggerWithPrefix(quietConsoleLogger, "[Reflag]");
401 |
402 | // Create the context object making sure to clone the user and company objects
403 | this.context = {
404 | user: opts?.user?.id ? { ...opts.user } : undefined,
405 | company: opts?.company?.id ? { ...opts.company } : undefined,
406 | other: { ...opts?.otherContext, ...opts?.other },
407 | };
408 |
409 | this.config = {
410 | apiBaseUrl: opts?.apiBaseUrl ?? defaultConfig.apiBaseUrl,
411 | appBaseUrl: opts?.appBaseUrl ?? defaultConfig.appBaseUrl,
412 | sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl,
413 | enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking,
414 | offline: opts?.offline ?? defaultConfig.offline,
415 | bootstrapped:
416 | opts && "bootstrappedFlags" in opts && !!opts.bootstrappedFlags,
417 | };
418 |
419 | this.requestFeedbackOptions = {
420 | position: opts?.feedback?.ui?.position,
421 | translations: opts?.feedback?.ui?.translations,
422 | };
423 |
424 | this.httpClient = new HttpClient(this.publishableKey, {
425 | baseUrl: this.config.apiBaseUrl,
426 | sdkVersion: opts?.sdkVersion,
427 | credentials: opts?.credentials,
428 | });
429 |
430 | this.flagsClient = new FlagsClient(
431 | this.httpClient,
432 | this.context,
433 | this.logger,
434 | {
435 | bootstrappedFlags: opts.bootstrappedFlags,
436 | expireTimeMs: opts.expireTimeMs,
437 | staleTimeMs: opts.staleTimeMs,
438 | staleWhileRevalidate: opts.staleWhileRevalidate,
439 | timeoutMs: opts.timeoutMs,
440 | fallbackFlags: opts.fallbackFlags,
441 | offline: this.config.offline,
442 | },
443 | );
444 |
445 | if (
446 | !this.config.offline &&
447 | this.context?.user &&
448 | !isNode && // do not prompt on server-side
449 | opts?.feedback?.enableAutoFeedback !== false // default to on
450 | ) {
451 | if (isMobile) {
452 | this.logger.warn(
453 | "Feedback prompting is not supported on mobile devices",
454 | );
455 | } else {
456 | this.autoFeedback = new AutoFeedback(
457 | this.config.sseBaseUrl,
458 | this.logger,
459 | this.httpClient,
460 | opts?.feedback?.autoFeedbackHandler,
461 | String(this.context.user?.id),
462 | opts?.feedback?.ui?.position,
463 | opts?.feedback?.ui?.translations,
464 | );
465 | }
466 | }
467 |
468 | if (shouldShowToolbar(opts)) {
469 | this.logger.info("opening toolbar toggler");
470 | showToolbarToggle({
471 | reflagClient: this,
472 | position:
473 | typeof opts.toolbar === "object" ? opts.toolbar.position : undefined,
474 | });
475 | }
476 |
477 | // Register hooks
478 | this.hooks = new HooksManager();
479 | this.flagsClient.onUpdated(() => {
480 | this.hooks.trigger("flagsUpdated", this.flagsClient.getFlags());
481 | });
482 | }
483 |
484 | /**
485 | * Initialize the Reflag SDK.
486 | *
487 | * Must be called before calling other SDK methods.
488 | */
489 | async initialize() {
490 | if (this.state === "initializing" || this.state === "initialized") {
491 | this.logger.warn(`"Reflag client already ${this.state}`);
492 | return;
493 | }
494 | this.setState("initializing");
495 |
496 | const start = Date.now();
497 | if (this.autoFeedback && !IS_SERVER) {
498 | // do not block on automated feedback surveys initialization
499 | this.autoFeedbackInit = this.autoFeedback.initialize().catch((e) => {
500 | this.logger.error("error initializing automated feedback surveys", e);
501 | });
502 | }
503 |
504 | await this.flagsClient.initialize();
505 |
506 | if (!this.config.bootstrapped) {
507 | if (this.context.user && this.config.enableTracking) {
508 | this.user().catch((e) => {
509 | this.logger.error("error sending user", e);
510 | });
511 | }
512 |
513 | if (this.context.company && this.config.enableTracking) {
514 | this.company().catch((e) => {
515 | this.logger.error("error sending company", e);
516 | });
517 | }
518 | }
519 |
520 | this.logger.info(
521 | "Reflag initialized in " +
522 | Math.round(Date.now() - start) +
523 | "ms" +
524 | (this.config.offline ? " (offline mode)" : ""),
525 | );
526 | this.setState("initialized");
527 | }
528 |
529 | /**
530 | * Stop the SDK.
531 | * This will stop any automated feedback surveys.
532 | *
533 | **/
534 | async stop() {
535 | if (this.autoFeedback) {
536 | // ensure fully initialized before stopping
537 | await this.autoFeedbackInit;
538 | this.autoFeedback.stop();
539 | }
540 |
541 | this.flagsClient.stop();
542 | this.setState("stopped");
543 | }
544 |
545 | getState() {
546 | return this.state;
547 | }
548 |
549 | /**
550 | * Add an event listener
551 | *
552 | * @param type Type of events to listen for
553 | * @param handler The function to call when the event is triggered.
554 | * @returns A function to remove the hook.
555 | */
556 | on<THookType extends keyof HookArgs>(
557 | type: THookType,
558 | handler: (args0: HookArgs[THookType]) => void,
559 | ) {
560 | return this.hooks.addHook(type, handler);
561 | }
562 |
563 | /**
564 | * Remove an event listener
565 | *
566 | * @param type Type of event to remove.
567 | * @param handler The same function that was passed to `on`.
568 | *
569 | * @returns A function to remove the hook.
570 | */
571 | off<THookType extends keyof HookArgs>(
572 | type: THookType,
573 | handler: (args0: HookArgs[THookType]) => void,
574 | ) {
575 | this.hooks.removeHook(type, handler);
576 | }
577 |
578 | /**
579 | * Get the current context.
580 | */
581 | getContext() {
582 | return this.context;
583 | }
584 |
585 | /**
586 | * Get the current configuration.
587 | */
588 | getConfig() {
589 | return this.config;
590 | }
591 |
592 | /**
593 | * Update the user context.
594 | * Performs a shallow merge with the existing user context.
595 | * It will not update the context if nothing has changed.
596 | *
597 | * @param user
598 | */
599 | async updateUser(user: { [key: string]: string | number | undefined }) {
600 | const userIdChanged = user.id && user.id !== this.context.user?.id;
601 | const newUserContext = {
602 | ...this.context.user,
603 | ...user,
604 | id: user.id ?? this.context.user?.id,
605 | };
606 |
607 | // Nothing has changed, skipping update
608 | if (deepEqual(this.context.user, newUserContext)) return;
609 | this.context.user = newUserContext;
610 | void this.user();
611 |
612 | // Update the feedback user if the user ID has changed
613 | if (userIdChanged) {
614 | void this.updateAutoFeedbackUser(String(user.id));
615 | }
616 |
617 | await this.flagsClient.setContext(this.context);
618 | }
619 |
620 | /**
621 | * Update the company context.
622 | * Performs a shallow merge with the existing company context.
623 | * It will not update the context if nothing has changed.
624 | *
625 | * @param company The company details.
626 | */
627 | async updateCompany(company: { [key: string]: string | number | undefined }) {
628 | const newCompanyContext = {
629 | ...this.context.company,
630 | ...company,
631 | id: company.id ?? this.context.company?.id,
632 | };
633 |
634 | // Nothing has changed, skipping update
635 | if (deepEqual(this.context.company, newCompanyContext)) return;
636 | this.context.company = newCompanyContext;
637 | void this.company();
638 |
639 | await this.flagsClient.setContext(this.context);
640 | }
641 |
642 | /**
643 | * Update the company context.
644 | * Performs a shallow merge with the existing company context.
645 | * It will not update the context if nothing has changed.
646 | *
647 | * @param otherContext Additional context.
648 | */
649 | async updateOtherContext(otherContext: {
650 | [key: string]: string | number | undefined;
651 | }) {
652 | const newOtherContext = {
653 | ...this.context.other,
654 | ...otherContext,
655 | };
656 |
657 | // Nothing has changed, skipping update
658 | if (deepEqual(this.context.other, newOtherContext)) return;
659 | this.context.other = newOtherContext;
660 |
661 | await this.flagsClient.setContext(this.context);
662 | }
663 |
664 | /**
665 | * Update the context.
666 | * Replaces the existing context with a new context.
667 | *
668 | * @param context The context to update.
669 | */
670 | async setContext({ otherContext, ...context }: ReflagDeprecatedContext) {
671 | const userIdChanged =
672 | context.user?.id && context.user.id !== this.context.user?.id;
673 |
674 | // Create a new context object making sure to clone the user and company objects
675 | const newContext = {
676 | user: context.user?.id ? { ...context.user } : undefined,
677 | company: context.company?.id ? { ...context.company } : undefined,
678 | other: { ...otherContext, ...context.other },
679 | };
680 |
681 | if (!context.user?.id) {
682 | this.logger.warn("No user Id provided in context, user will be ignored");
683 | }
684 | if (!context.company?.id) {
685 | this.logger.warn(
686 | "No company Id provided in context, company will be ignored",
687 | );
688 | }
689 |
690 | // Nothing has changed, skipping update
691 | if (deepEqual(this.context, newContext)) return;
692 | this.context = newContext;
693 |
694 | if (context.company) {
695 | void this.company();
696 | }
697 |
698 | if (context.user) {
699 | void this.user();
700 | // Update the automatic feedback user if the user ID has changed
701 | if (userIdChanged) {
702 | void this.updateAutoFeedbackUser(String(context.user.id));
703 | }
704 | }
705 |
706 | await this.flagsClient.setContext(this.context);
707 | }
708 |
709 | /**
710 | * Update the flags.
711 | *
712 | * @param flags The flags to update.
713 | * @param triggerEvent Whether to trigger the `flagsUpdated` event.
714 | */
715 | updateFlags(flags: RawFlags, triggerEvent = true) {
716 | this.flagsClient.setFetchedFlags(flags, triggerEvent);
717 | }
718 |
719 | /**
720 | * Track an event in Reflag.
721 | *
722 | * @param eventName The name of the event.
723 | * @param attributes Any attributes you want to attach to the event.
724 | */
725 | async track(eventName: string, attributes?: Record<string, any> | null) {
726 | if (!this.context.user) {
727 | this.logger.warn("'track' call ignored. No user context provided");
728 | return;
729 | }
730 | if (!this.config.enableTracking) {
731 | this.logger.warn("'track' call ignored. 'enableTracking' is false");
732 | return;
733 | }
734 |
735 | if (this.config.offline) {
736 | return;
737 | }
738 |
739 | const payload: TrackedEvent = {
740 | userId: String(this.context.user.id),
741 | event: eventName,
742 | };
743 | if (attributes) payload.attributes = attributes;
744 | if (this.context.company?.id)
745 | payload.companyId = String(this.context.company?.id);
746 |
747 | const res = await this.httpClient.post({ path: `/event`, body: payload });
748 | this.logger.debug(`sent event`, res);
749 |
750 | this.hooks.trigger("track", {
751 | eventName,
752 | attributes,
753 | user: this.context.user,
754 | company: this.context.company,
755 | });
756 | return res;
757 | }
758 |
759 | /**
760 | * Submit user feedback to Reflag. Must include either `score` or `comment`, or both.
761 | *
762 | * @param payload The feedback details to submit.
763 | * @returns The server response.
764 | */
765 | async feedback(payload: Feedback) {
766 | if (this.config.offline) {
767 | return;
768 | }
769 |
770 | const userId =
771 | payload.userId ||
772 | (this.context.user?.id ? String(this.context.user?.id) : undefined);
773 |
774 | const companyId =
775 | payload.companyId ||
776 | (this.context.company?.id ? String(this.context.company?.id) : undefined);
777 |
778 | return await feedback(this.httpClient, this.logger, {
779 | userId,
780 | companyId,
781 | ...payload,
782 | });
783 | }
784 |
785 | /**
786 | * Display the Reflag feedback form UI programmatically.
787 | *
788 | * This can be used to collect feedback from users in Reflag in cases where Automated Feedback Surveys isn't appropriate.
789 | *
790 | * @param options
791 | */
792 | requestFeedback(options: RequestFeedbackData) {
793 | if (!this.context.user?.id) {
794 | this.logger.error(
795 | "`requestFeedback` call ignored. No `user` context provided at initialization",
796 | );
797 | return;
798 | }
799 |
800 | if (!options.flagKey) {
801 | this.logger.error(
802 | "`requestFeedback` call ignored. No `flagKey` provided",
803 | );
804 | return;
805 | }
806 |
807 | const feedbackData = {
808 | flagKey: options.flagKey,
809 | companyId:
810 | options.companyId ||
811 | (this.context.company?.id
812 | ? String(this.context.company?.id)
813 | : undefined),
814 | source: "widget" as const,
815 | } satisfies Feedback;
816 |
817 | // Wait a tick before opening the feedback form,
818 | // to prevent the same click from closing it.
819 | setTimeout(() => {
820 | feedbackLib.openFeedbackForm({
821 | key: options.flagKey,
822 | title: options.title,
823 | position: options.position || this.requestFeedbackOptions.position,
824 | translations:
825 | options.translations || this.requestFeedbackOptions.translations,
826 | openWithCommentVisible: options.openWithCommentVisible,
827 | onClose: options.onClose,
828 | onDismiss: options.onDismiss,
829 | onScoreSubmit: async (data) => {
830 | const res = await this.feedback({
831 | ...feedbackData,
832 | ...data,
833 | });
834 |
835 | if (res) {
836 | const json = await res.json();
837 | return { feedbackId: json.feedbackId };
838 | }
839 | return { feedbackId: undefined };
840 | },
841 | onSubmit: async (data) => {
842 | // Default onSubmit handler
843 | await this.feedback({
844 | ...feedbackData,
845 | ...data,
846 | });
847 |
848 | options.onAfterSubmit?.(data);
849 | },
850 | });
851 | }, 1);
852 | }
853 |
854 | /**
855 | * @deprecated Use `getFlags` instead.
856 | */
857 | getFeatures() {
858 | return this.getFlags();
859 | }
860 |
861 | /**
862 | * Returns a map of enabled flags.
863 | * Accessing a flag will *not* send a check event
864 | * and `isEnabled` does not take any flag overrides
865 | * into account.
866 | *
867 | * @returns Map of flags.
868 | */
869 | getFlags(): RawFlags {
870 | return this.flagsClient.getFlags();
871 | }
872 |
873 | /**
874 | * @deprecated Use `getFlag` instead.
875 | */
876 | getFeature(flagKey: string) {
877 | return this.getFlag(flagKey);
878 | }
879 |
880 | /**
881 | * Return a flag. Accessing `isEnabled` or `config` will automatically send a `check` event.
882 | *
883 | * @param flagKey - The key of the flag to get.
884 | * @returns A flag.
885 | */
886 | getFlag(flagKey: string): Flag {
887 | const f = this.getFlags()[flagKey];
888 |
889 | // eslint-disable-next-line @typescript-eslint/no-this-alias
890 | const self = this;
891 | const value = f?.isEnabledOverride ?? f?.isEnabled ?? false;
892 | const config = f?.config
893 | ? {
894 | key: f.config.key,
895 | payload: f.config.payload,
896 | }
897 | : { key: undefined, payload: undefined };
898 |
899 | return {
900 | get isEnabled() {
901 | self
902 | .sendCheckEvent({
903 | action: "check-is-enabled",
904 | key: flagKey,
905 | version: f?.targetingVersion,
906 | ruleEvaluationResults: f?.ruleEvaluationResults,
907 | missingContextFields: f?.missingContextFields,
908 | value,
909 | })
910 | .catch(() => {
911 | // ignore
912 | });
913 | return value;
914 | },
915 | get config() {
916 | self
917 | .sendCheckEvent({
918 | action: "check-config",
919 | key: flagKey,
920 | version: f?.config?.version,
921 | ruleEvaluationResults: f?.config?.ruleEvaluationResults,
922 | missingContextFields: f?.config?.missingContextFields,
923 | value: f?.config && {
924 | key: f.config.key,
925 | payload: f.config.payload,
926 | },
927 | })
928 | .catch(() => {
929 | // ignore
930 | });
931 |
932 | return config;
933 | },
934 | track: () => this.track(flagKey),
935 | requestFeedback: (
936 | options: Omit<RequestFeedbackData, "flagKey" | "featureId">,
937 | ) => {
938 | this.requestFeedback({
939 | flagKey,
940 | ...options,
941 | });
942 | },
943 | isEnabledOverride: this.flagsClient.getFlagOverride(flagKey),
944 | setIsEnabledOverride(isEnabled: boolean | null) {
945 | self.flagsClient.setFlagOverride(flagKey, isEnabled);
946 | },
947 | };
948 | }
949 |
950 | private setState(state: State) {
951 | this.state = state;
952 | this.hooks.trigger("stateUpdated", state);
953 | }
954 |
955 | private sendCheckEvent(checkEvent: CheckEvent) {
956 | return this.flagsClient.sendCheckEvent(checkEvent, () => {
957 | this.hooks.trigger("check", checkEvent);
958 | });
959 | }
960 |
961 | /**
962 | * Send attributes to Reflag for the current user
963 | */
964 | private async user() {
965 | if (!this.context.user) {
966 | this.logger.warn(
967 | "`user` call ignored. No user context provided at initialization",
968 | );
969 | return;
970 | }
971 |
972 | if (this.config.offline) {
973 | return;
974 | }
975 |
976 | const { id, ...attributes } = this.context.user;
977 | const payload: User = {
978 | userId: String(id),
979 | attributes,
980 | };
981 | const res = await this.httpClient.post({ path: `/user`, body: payload });
982 | this.logger.debug(`sent user`, res);
983 |
984 | this.hooks.trigger("user", this.context.user);
985 | return res;
986 | }
987 |
988 | /**
989 | * Send attributes to Reflag for the current company.
990 | */
991 | private async company() {
992 | if (!this.context.user) {
993 | this.logger.warn(
994 | "`company` call ignored. No user context provided at initialization",
995 | );
996 | return;
997 | }
998 |
999 | if (!this.context.company) {
1000 | this.logger.warn(
1001 | "`company` call ignored. No company context provided at initialization",
1002 | );
1003 | return;
1004 | }
1005 |
1006 | if (this.config.offline) {
1007 | return;
1008 | }
1009 |
1010 | const { id, ...attributes } = this.context.company;
1011 | const payload: Company = {
1012 | userId: String(this.context.user.id),
1013 | companyId: String(id),
1014 | attributes,
1015 | };
1016 |
1017 | const res = await this.httpClient.post({ path: `/company`, body: payload });
1018 | this.logger.debug(`sent company`, res);
1019 | this.hooks.trigger("company", this.context.company);
1020 | return res;
1021 | }
1022 |
1023 | private async updateAutoFeedbackUser(userId: string) {
1024 | if (!this.autoFeedback) {
1025 | return;
1026 | }
1027 | // Ensure fully initialized before updating the user
1028 | await this.autoFeedbackInit;
1029 | await this.autoFeedback.setUser(userId);
1030 | }
1031 | }
1032 |
```
--------------------------------------------------------------------------------
/packages/flag-evaluation/test/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2 |
3 | import {
4 | evaluate,
5 | evaluateFlagRules,
6 | EvaluationParams,
7 | flattenJSON,
8 | hashInt,
9 | newEvaluator,
10 | unflattenJSON,
11 | } from "../src";
12 |
13 | const flag = {
14 | flagKey: "flag",
15 | rules: [
16 | {
17 | value: true,
18 | filter: {
19 | type: "group",
20 | operator: "and",
21 | filters: [
22 | {
23 | type: "context",
24 | field: "company.id",
25 | operator: "IS",
26 | values: ["company1"],
27 | },
28 | {
29 | type: "rolloutPercentage",
30 | key: "flag",
31 | partialRolloutAttribute: "company.id",
32 | partialRolloutThreshold: 100000,
33 | },
34 | ],
35 | },
36 | },
37 | ],
38 | } satisfies Omit<EvaluationParams<true>, "context">;
39 |
40 | describe("evaluate flag targeting integration ", () => {
41 | it("evaluates all kinds of filters", async () => {
42 | const res = evaluateFlagRules({
43 | flagKey: "flag",
44 | rules: [
45 | {
46 | value: true,
47 | filter: {
48 | type: "group",
49 | operator: "and",
50 | filters: [
51 | {
52 | type: "context",
53 | field: "company.id",
54 | operator: "IS",
55 | values: ["company1"],
56 | },
57 | {
58 | type: "rolloutPercentage",
59 | key: "flag",
60 | partialRolloutAttribute: "company.id",
61 | partialRolloutThreshold: 99999,
62 | },
63 | {
64 | type: "group",
65 | operator: "or",
66 | filters: [
67 | {
68 | type: "context",
69 | field: "company.id",
70 | operator: "IS",
71 | values: ["company2"],
72 | },
73 | {
74 | type: "negation",
75 | filter: {
76 | type: "context",
77 | field: "company.id",
78 | operator: "IS",
79 | values: ["company3"],
80 | },
81 | },
82 | ],
83 | },
84 | {
85 | type: "negation",
86 | filter: {
87 | type: "constant",
88 | value: false,
89 | },
90 | },
91 | ],
92 | },
93 | },
94 | ],
95 | context: {
96 | "company.id": "company1",
97 | },
98 | });
99 |
100 | expect(res).toEqual({
101 | value: true,
102 | context: {
103 | "company.id": "company1",
104 | },
105 | flagKey: "flag",
106 | missingContextFields: [],
107 | reason: "rule #0 matched",
108 | ruleEvaluationResults: [true],
109 | });
110 | });
111 |
112 | it("evaluates flag when there's no matching rule", async () => {
113 | const res = evaluateFlagRules({
114 | ...flag,
115 | context: {
116 | company: {
117 | id: "wrong value",
118 | },
119 | },
120 | });
121 |
122 | expect(res).toEqual({
123 | value: undefined,
124 | context: {
125 | "company.id": "wrong value",
126 | },
127 | flagKey: "flag",
128 | missingContextFields: [],
129 | reason: "no matched rules",
130 | ruleEvaluationResults: [false],
131 | });
132 | });
133 |
134 | it("evaluates targeting when there's a matching rule", async () => {
135 | const context = {
136 | company: {
137 | id: "company1",
138 | },
139 | };
140 |
141 | const res = evaluateFlagRules({
142 | ...flag,
143 | context,
144 | });
145 |
146 | expect(res).toEqual({
147 | value: true,
148 | context: {
149 | "company.id": "company1",
150 | },
151 | flagKey: "flag",
152 | missingContextFields: [],
153 | reason: "rule #0 matched",
154 | ruleEvaluationResults: [true],
155 | });
156 | });
157 |
158 | it("evaluates flag with missing values", async () => {
159 | const res = evaluateFlagRules({
160 | flagKey: "flag",
161 | rules: [
162 | {
163 | value: { custom: "value" },
164 | filter: {
165 | type: "group",
166 | operator: "and",
167 | filters: [
168 | {
169 | type: "context",
170 | field: "some_field",
171 | operator: "IS",
172 | values: [""],
173 | },
174 | {
175 | type: "rolloutPercentage",
176 | key: "flag",
177 | partialRolloutAttribute: "some_field",
178 | partialRolloutThreshold: 99000,
179 | },
180 | ],
181 | },
182 | },
183 | ],
184 | context: {
185 | some_field: "",
186 | },
187 | });
188 |
189 | expect(res).toEqual({
190 | context: {
191 | some_field: "",
192 | },
193 | value: { custom: "value" },
194 | flagKey: "flag",
195 | missingContextFields: [],
196 | reason: "rule #0 matched",
197 | ruleEvaluationResults: [true],
198 | });
199 | });
200 |
201 | it("returns list of missing context keys ", async () => {
202 | const res = evaluateFlagRules({
203 | ...flag,
204 | context: {},
205 | });
206 |
207 | expect(res).toEqual({
208 | context: {},
209 | value: undefined,
210 | reason: "no matched rules",
211 | flagKey: "flag",
212 | missingContextFields: ["company.id"],
213 | ruleEvaluationResults: [false],
214 | });
215 | });
216 |
217 | it("fails evaluation and includes key in missing keys when rollout attribute is missing from context", async () => {
218 | const res = evaluateFlagRules({
219 | flagKey: "flag-1",
220 | rules: [
221 | {
222 | value: 123,
223 | filter: {
224 | type: "rolloutPercentage" as const,
225 | key: "flag-1",
226 | partialRolloutAttribute: "happening.id",
227 | partialRolloutThreshold: 50000,
228 | },
229 | },
230 | ],
231 | context: {},
232 | });
233 |
234 | expect(res).toEqual({
235 | flagKey: "flag-1",
236 | context: {},
237 | value: undefined,
238 | reason: "no matched rules",
239 | missingContextFields: ["happening.id"],
240 | ruleEvaluationResults: [false],
241 | });
242 | });
243 |
244 | it("evaluates optimized rule evaluations correctly", async () => {
245 | const res = newEvaluator([
246 | {
247 | value: true,
248 | filter: {
249 | type: "group",
250 | operator: "and",
251 | filters: [
252 | {
253 | type: "context",
254 | field: "company.id",
255 | operator: "IS",
256 | values: ["company1"],
257 | },
258 | {
259 | type: "rolloutPercentage",
260 | key: "flag",
261 | partialRolloutAttribute: "company.id",
262 | partialRolloutThreshold: 99999,
263 | },
264 | {
265 | type: "group",
266 | operator: "or",
267 | filters: [
268 | {
269 | type: "context",
270 | field: "company.id",
271 | operator: "ANY_OF",
272 | values: ["company2"],
273 | },
274 | {
275 | type: "negation",
276 | filter: {
277 | type: "context",
278 | field: "company.id",
279 | operator: "IS",
280 | values: ["company3"],
281 | },
282 | },
283 | ],
284 | },
285 | {
286 | type: "negation",
287 | filter: {
288 | type: "constant",
289 | value: false,
290 | },
291 | },
292 | ],
293 | },
294 | },
295 | ])(
296 | {
297 | "company.id": "company1",
298 | },
299 | "flag",
300 | );
301 |
302 | expect(res).toEqual({
303 | value: true,
304 | context: {
305 | "company.id": "company1",
306 | },
307 | flagKey: "flag",
308 | missingContextFields: [],
309 | reason: "rule #0 matched",
310 | ruleEvaluationResults: [true],
311 | });
312 | });
313 |
314 | describe("SET and NOT_SET operators", () => {
315 | it("should handle `SET` operator with missing field value", () => {
316 | const res = evaluateFlagRules({
317 | flagKey: "test_flag",
318 | rules: [
319 | {
320 | value: true,
321 | filter: {
322 | type: "context",
323 | field: "user.name",
324 | operator: "SET",
325 | values: [],
326 | },
327 | },
328 | ],
329 | context: {},
330 | });
331 |
332 | expect(res).toEqual({
333 | flagKey: "test_flag",
334 | value: undefined,
335 | context: {},
336 | ruleEvaluationResults: [false],
337 | reason: "no matched rules",
338 | missingContextFields: [],
339 | });
340 | });
341 |
342 | it("should handle `NOT_SET` operator with missing field value", () => {
343 | const res = evaluateFlagRules({
344 | flagKey: "test_flag",
345 | rules: [
346 | {
347 | value: true,
348 | filter: {
349 | type: "context",
350 | field: "user.name",
351 | operator: "NOT_SET",
352 | values: [],
353 | },
354 | },
355 | ],
356 | context: {},
357 | });
358 |
359 | expect(res).toEqual({
360 | flagKey: "test_flag",
361 | value: true,
362 | context: {},
363 | ruleEvaluationResults: [true],
364 | reason: "rule #0 matched",
365 | missingContextFields: [],
366 | });
367 | });
368 |
369 | it("should handle `SET` operator with empty string field value", () => {
370 | const res = evaluateFlagRules({
371 | flagKey: "test_flag",
372 | rules: [
373 | {
374 | value: true,
375 | filter: {
376 | type: "context",
377 | field: "user.name",
378 | operator: "SET",
379 | values: [],
380 | },
381 | },
382 | ],
383 | context: {
384 | user: {
385 | name: "",
386 | },
387 | },
388 | });
389 |
390 | expect(res).toEqual({
391 | flagKey: "test_flag",
392 | value: undefined,
393 | context: {
394 | "user.name": "",
395 | },
396 | ruleEvaluationResults: [false],
397 | reason: "no matched rules",
398 | missingContextFields: [],
399 | });
400 | });
401 |
402 | it("should handle `NOT_SET` operator with empty string field value", () => {
403 | const res = evaluateFlagRules({
404 | flagKey: "test_flag",
405 | rules: [
406 | {
407 | value: true,
408 | filter: {
409 | type: "context",
410 | field: "user.name",
411 | operator: "NOT_SET",
412 | values: [],
413 | },
414 | },
415 | ],
416 | context: {
417 | user: {
418 | name: "",
419 | },
420 | },
421 | });
422 |
423 | expect(res).toEqual({
424 | flagKey: "test_flag",
425 | value: true,
426 | context: {
427 | "user.name": "",
428 | },
429 | ruleEvaluationResults: [true],
430 | reason: "rule #0 matched",
431 | missingContextFields: [],
432 | });
433 | });
434 | });
435 |
436 | it.each([
437 | {
438 | context: { "company.id": "company1" },
439 | expected: true,
440 | },
441 | {
442 | context: { "company.id": "company2" },
443 | expected: true,
444 | },
445 | {
446 | context: { "company.id": "company3" },
447 | expected: false,
448 | },
449 | ])(
450 | "%#: evaluates optimized rule evaluations correctly",
451 | async ({ context, expected }) => {
452 | const evaluator = newEvaluator([
453 | {
454 | value: true,
455 | filter: {
456 | type: "group",
457 | operator: "and",
458 | filters: [
459 | {
460 | type: "context",
461 | field: "company.id",
462 | operator: "ANY_OF",
463 | values: ["company1", "company2"],
464 | },
465 | ],
466 | },
467 | },
468 | ]);
469 |
470 | const res = evaluator(context, "flag-1");
471 | expect(res.value ?? false).toEqual(expected);
472 | },
473 | );
474 |
475 | describe("DATE_AFTER and DATE_BEFORE in flag rules", () => {
476 | it("should evaluate DATE_AFTER operator in flag rules", () => {
477 | const res = evaluateFlagRules({
478 | flagKey: "time_based_flag",
479 | rules: [
480 | {
481 | value: "enabled",
482 | filter: {
483 | type: "context",
484 | field: "user.createdAt",
485 | operator: "DATE_AFTER",
486 | values: ["2024-01-01"],
487 | },
488 | },
489 | ],
490 | context: {
491 | user: {
492 | createdAt: "2024-06-15",
493 | },
494 | },
495 | });
496 |
497 | expect(res).toEqual({
498 | flagKey: "time_based_flag",
499 | value: "enabled",
500 | context: {
501 | "user.createdAt": "2024-06-15",
502 | },
503 | ruleEvaluationResults: [true],
504 | reason: "rule #0 matched",
505 | missingContextFields: [],
506 | });
507 | });
508 |
509 | it("should evaluate DATE_BEFORE operator in flag rules", () => {
510 | const res = evaluateFlagRules({
511 | flagKey: "legacy_flag",
512 | rules: [
513 | {
514 | value: "enabled",
515 | filter: {
516 | type: "context",
517 | field: "user.lastLogin",
518 | operator: "DATE_BEFORE",
519 | values: ["2024-12-31"],
520 | },
521 | },
522 | ],
523 | context: {
524 | user: {
525 | lastLogin: "2024-01-15",
526 | },
527 | },
528 | });
529 |
530 | expect(res).toEqual({
531 | flagKey: "legacy_flag",
532 | value: "enabled",
533 | context: {
534 | "user.lastLogin": "2024-01-15",
535 | },
536 | ruleEvaluationResults: [true],
537 | reason: "rule #0 matched",
538 | missingContextFields: [],
539 | });
540 | });
541 |
542 | it("should handle complex rules with DATE_AFTER and DATE_BEFORE in groups", () => {
543 | const res = evaluateFlagRules({
544 | flagKey: "time_window_flag",
545 | rules: [
546 | {
547 | value: "active",
548 | filter: {
549 | type: "group",
550 | operator: "and",
551 | filters: [
552 | {
553 | type: "context",
554 | field: "event.startDate",
555 | operator: "DATE_AFTER",
556 | values: ["2024-01-01"],
557 | },
558 | {
559 | type: "context",
560 | field: "event.endDate",
561 | operator: "DATE_BEFORE",
562 | values: ["2024-12-31"],
563 | },
564 | ],
565 | },
566 | },
567 | ],
568 | context: {
569 | event: {
570 | startDate: "2024-06-01",
571 | endDate: "2024-11-30",
572 | },
573 | },
574 | });
575 |
576 | expect(res).toEqual({
577 | flagKey: "time_window_flag",
578 | value: "active",
579 | context: {
580 | "event.startDate": "2024-06-01",
581 | "event.endDate": "2024-11-30",
582 | },
583 | ruleEvaluationResults: [true],
584 | reason: "rule #0 matched",
585 | missingContextFields: [],
586 | });
587 | });
588 |
589 | it("should fail when DATE_AFTER condition is not met", () => {
590 | const res = evaluateFlagRules({
591 | flagKey: "future_flag",
592 | rules: [
593 | {
594 | value: "enabled",
595 | filter: {
596 | type: "context",
597 | field: "user.signupDate",
598 | operator: "DATE_AFTER",
599 | values: ["2024-12-01"],
600 | },
601 | },
602 | ],
603 | context: {
604 | user: {
605 | signupDate: "2024-01-15", // Too early
606 | },
607 | },
608 | });
609 |
610 | expect(res).toEqual({
611 | flagKey: "future_flag",
612 | value: undefined,
613 | context: {
614 | "user.signupDate": "2024-01-15",
615 | },
616 | ruleEvaluationResults: [false],
617 | reason: "no matched rules",
618 | missingContextFields: [],
619 | });
620 | });
621 |
622 | it("should fail when DATE_BEFORE condition is not met", () => {
623 | const res = evaluateFlagRules({
624 | flagKey: "past_flag",
625 | rules: [
626 | {
627 | value: "enabled",
628 | filter: {
629 | type: "context",
630 | field: "user.lastActivity",
631 | operator: "DATE_BEFORE",
632 | values: ["2024-01-01"],
633 | },
634 | },
635 | ],
636 | context: {
637 | user: {
638 | lastActivity: "2024-06-15", // Too late
639 | },
640 | },
641 | });
642 |
643 | expect(res).toEqual({
644 | flagKey: "past_flag",
645 | value: undefined,
646 | context: {
647 | "user.lastActivity": "2024-06-15",
648 | },
649 | ruleEvaluationResults: [false],
650 | reason: "no matched rules",
651 | missingContextFields: [],
652 | });
653 | });
654 |
655 | it("should work with optimized evaluator", () => {
656 | const evaluator = newEvaluator([
657 | {
658 | value: "time_sensitive",
659 | filter: {
660 | type: "group",
661 | operator: "and",
662 | filters: [
663 | {
664 | type: "context",
665 | field: "user.subscriptionDate",
666 | operator: "DATE_AFTER",
667 | values: ["2024-01-01"],
668 | },
669 | {
670 | type: "context",
671 | field: "user.trialEndDate",
672 | operator: "DATE_BEFORE",
673 | values: ["2024-12-31"],
674 | },
675 | ],
676 | },
677 | },
678 | ]);
679 |
680 | const res = evaluator(
681 | {
682 | user: {
683 | subscriptionDate: "2024-03-15",
684 | trialEndDate: "2024-09-30",
685 | },
686 | },
687 | "subscription_flag",
688 | );
689 |
690 | expect(res).toEqual({
691 | flagKey: "subscription_flag",
692 | value: "time_sensitive",
693 | context: {
694 | "user.subscriptionDate": "2024-03-15",
695 | "user.trialEndDate": "2024-09-30",
696 | },
697 | ruleEvaluationResults: [true],
698 | reason: "rule #0 matched",
699 | missingContextFields: [],
700 | });
701 | });
702 | });
703 | });
704 |
705 | describe("operator evaluation", () => {
706 | beforeAll(() => {
707 | vi.useFakeTimers().setSystemTime(new Date("2024-01-10"));
708 | });
709 |
710 | afterAll(() => {
711 | vi.useRealTimers();
712 | });
713 |
714 | const tests = [
715 | ["value", "IS", "value", true],
716 | ["value", "IS", "wrong value", false],
717 | ["value", "IS_NOT", "value", false],
718 | ["value", "IS_NOT", "wrong value", true],
719 |
720 | ["value", "ANY_OF", "value", true],
721 | ["value", "ANY_OF", "nope", false],
722 | ["value", "NOT_ANY_OF", "value", false],
723 | ["value", "NOT_ANY_OF", "nope", true],
724 |
725 | ["value", "IS_TRUE", "", false],
726 | ["value", "IS_FALSE", "", false],
727 |
728 | ["value", "SET", "", true],
729 | ["", "SET", "", false],
730 | ["value", "NOT_SET", "", false],
731 | ["", "NOT_SET", "", true],
732 |
733 | // non numeric values should return false
734 | ["value", "GT", "value", false],
735 | ["value", "GT", "0", false],
736 | ["1", "GT", "0", true],
737 | ["2", "GT", "10", false],
738 | ["10", "GT", "2", true],
739 |
740 | ["value", "LT", "value", false],
741 | ["value", "LT", "0", false],
742 | ["0", "LT", "1", true],
743 | ["2", "LT", "10", true],
744 | ["10", "LT", "2", false],
745 |
746 | ["start VALUE end", "CONTAINS", "value", true],
747 | ["alue", "CONTAINS", "value", false],
748 | ["start VALUE end", "NOT_CONTAINS", "value", false],
749 | ["alue", "NOT_CONTAINS", "value", true],
750 |
751 | // today is 2024-01-10
752 | // 2024-01-10 - 5 days = 2024-01-05
753 | ["2024-01-15", "BEFORE", "5", false], // 2024-01-15 is before 2024-01-05 = false
754 | ["2024-01-15", "AFTER", "5", true], // 2024-01-15 is after 2024-01-05 = true
755 | ["2024-01-01", "BEFORE", "5", true], // 2024-01-01 is before 2024-01-05 = true
756 | ["2024-01-01", "AFTER", "5", false], // 2024-01-01 is after 2024-01-05 = false
757 | ] as const;
758 |
759 | for (const [value, op, filterValue, expected] of tests) {
760 | it(`evaluates '${value}' ${op} 2024-01-10 minus ${filterValue} days = ${expected}`, () => {
761 | const res = evaluate(value, op, [filterValue]);
762 | expect(res).toEqual(expected);
763 | });
764 | }
765 |
766 | describe("DATE_AFTER and DATE_BEFORE operators", () => {
767 | const dateTests = [
768 | // DATE_AFTER tests
769 | ["2024-01-15", "DATE_AFTER", "2024-01-10", true], // After
770 | ["2024-01-10", "DATE_AFTER", "2024-01-10", true], // Same date (>=)
771 | ["2024-01-05", "DATE_AFTER", "2024-01-10", false], // Before
772 | ["2024-12-31", "DATE_AFTER", "2024-01-01", true], // Much later
773 | ["2023-01-01", "DATE_AFTER", "2024-01-01", false], // Much earlier
774 |
775 | // DATE_BEFORE tests
776 | ["2024-01-05", "DATE_BEFORE", "2024-01-10", true], // Before
777 | ["2024-01-10", "DATE_BEFORE", "2024-01-10", true], // Same date (<=)
778 | ["2024-01-15", "DATE_BEFORE", "2024-01-10", false], // After
779 | ["2023-01-01", "DATE_BEFORE", "2024-01-01", true], // Much earlier
780 | ["2024-12-31", "DATE_BEFORE", "2024-01-01", false], // Much later
781 |
782 | // Edge cases with different date formats
783 | ["2024-01-10T10:30:00Z", "DATE_AFTER", "2024-01-10T10:00:00Z", true], // ISO format with time
784 | ["2024-01-10T09:30:00Z", "DATE_BEFORE", "2024-01-10T10:00:00Z", true], // ISO format with time
785 | [
786 | "2024-01-10T10:30:00.123Z",
787 | "DATE_AFTER",
788 | "2024-01-10T10:00:00.000Z",
789 | true,
790 | ], // ISO format with time and milliseconds
791 | [
792 | "2024-01-10T09:30:00.123Z",
793 | "DATE_BEFORE",
794 | "2024-01-10T10:00:00.000Z",
795 | true,
796 | ], // ISO format with time and milliseconds
797 | ["01/15/2024", "DATE_AFTER", "01/10/2024", true], // US format
798 | ["01/05/2024", "DATE_BEFORE", "01/10/2024", true], // US format
799 | ] as const;
800 |
801 | for (const [fieldValue, operator, filterValue, expected] of dateTests) {
802 | it(`evaluates '${fieldValue}' ${operator} '${filterValue}' = ${expected}`, () => {
803 | const res = evaluate(fieldValue, operator, [filterValue]);
804 | expect(res).toEqual(expected);
805 | });
806 | }
807 |
808 | it("handles invalid date formats gracefully", () => {
809 | // Invalid dates should result in NaN comparisons and return false
810 | expect(evaluate("invalid-date", "DATE_AFTER", ["2024-01-10"])).toBe(
811 | false,
812 | );
813 | expect(evaluate("2024-01-10", "DATE_AFTER", ["invalid-date"])).toBe(
814 | false,
815 | );
816 | expect(evaluate("invalid-date", "DATE_BEFORE", ["2024-01-10"])).toBe(
817 | false,
818 | );
819 | expect(evaluate("2024-01-10", "DATE_BEFORE", ["invalid-date"])).toBe(
820 | false,
821 | );
822 | });
823 | });
824 | });
825 |
826 | describe("rollout hash", () => {
827 | const tests = [
828 | ["EEuoT8KShb", 38026],
829 | ["h7BOkvks5W", 81440],
830 | ["IZeSn3LCfJ", 80149],
831 | ["jxYGR0k2eG", 70348],
832 | ["VnaiKHgo1E", 82432],
833 | ["I3R27J9tGN", 88564],
834 | ["JoCeRRF5wm", 67104],
835 | ["D9yQyxGKlc", 90226],
836 | ["gvfTO4h4Je", 98400],
837 | ["zF5iPhvJuw", 53236],
838 | ["jMBqhV9Lzr", 99182],
839 | ["HQtiM6m2sM", 22123],
840 | ["O4VD9CdVMq", 72700],
841 | ["lEI48g7tLX", 46266],
842 | ["s7sOvfaOQ3", 57198],
843 | ["WuCAxrsjwT", 12755],
844 | ["1UIruKyifl", 50838],
845 | ["f8Y0N3i97C", 42372],
846 | ["rA57gcwaXG", 44337],
847 | ["5zNThaRQuB", 33221],
848 | ["uLIHKFgFU2", 49832],
849 | ["Dq29RMUKnK", 75136],
850 | ["pNIWi69N81", 21686],
851 | ["2lJMZxGGwf", 7747],
852 | ["vJHqCdZmo5", 11319],
853 | ["qgDRZ2LFvu", 91245],
854 | ["iWSiN2Jcad", 13365],
855 | ["FTCF9ZRnIY", 65642],
856 | ["WxsLfsrQNw", 41778],
857 | ["9HgMS79hrG", 88627],
858 | ["BXrIz1JIiP", 44341],
859 | ["oMtRltWl6T", 85415],
860 | ["FKP9myTjTo", 5059],
861 | ["fqlZoZ4PhD", 91346],
862 | ["ohtHmrXWOB", 45678],
863 | ["X7xh1uYeTU", 96239],
864 | ["zXe7HkAtjC", 25732],
865 | ["AnAZ1gugGv", 62481],
866 | ["0mfxv840GT", 27268],
867 | ["eins7hyIvx", 70954],
868 | ["es9Wkj86PO", 48575],
869 | ["g3AZn8zuTe", 44126],
870 | ["NHzNfl4ABW", 63844],
871 | ["0JZw2gHPg2", 53707],
872 | ["GKHMJ46sT9", 17572],
873 | ["ZHEpl9s0kN", 59526],
874 | ["wSMTYbrr75", 26396],
875 | ["0WEJv16LYd", 94865],
876 | ["dxV85hJ5t3", 96945],
877 | ["00d1uypkKy", 38988],
878 | ] as const;
879 |
880 | for (const [input, expected] of tests) {
881 | it(`evaluates '${input}' = ${expected}`, () => {
882 | const res = hashInt(input);
883 | expect(res).toEqual(expected);
884 | });
885 | }
886 | });
887 |
888 | describe("flattenJSON", () => {
889 | it("should handle an empty object correctly", () => {
890 | const input = {};
891 | const output = flattenJSON(input);
892 |
893 | expect(output).toEqual({});
894 | });
895 |
896 | it("should flatten a simple object", () => {
897 | const input = {
898 | a: {
899 | b: "value",
900 | },
901 | };
902 |
903 | const output = flattenJSON(input);
904 |
905 | expect(output).toEqual({
906 | "a.b": "value",
907 | });
908 | });
909 |
910 | it("should flatten nested objects", () => {
911 | const input = {
912 | a: {
913 | b: {
914 | c: {
915 | d: "value",
916 | },
917 | },
918 | },
919 | };
920 |
921 | const output = flattenJSON(input);
922 |
923 | expect(output).toEqual({
924 | "a.b.c.d": "value",
925 | });
926 | });
927 |
928 | it("should handle mixed data types", () => {
929 | const input = {
930 | a: {
931 | b: "string",
932 | c: 123,
933 | d: true,
934 | },
935 | };
936 |
937 | const output = flattenJSON(input);
938 |
939 | expect(output).toEqual({
940 | "a.b": "string",
941 | "a.c": "123",
942 | "a.d": "true",
943 | });
944 | });
945 |
946 | it("should flatten arrays", () => {
947 | const input = {
948 | a: ["value1", "value2", "value3"],
949 | };
950 |
951 | const output = flattenJSON(input);
952 |
953 | expect(output).toEqual({
954 | "a.0": "value1",
955 | "a.1": "value2",
956 | "a.2": "value3",
957 | });
958 | });
959 |
960 | it("should handle empty arrays", () => {
961 | const input = {
962 | a: [],
963 | };
964 |
965 | const output = flattenJSON(input);
966 |
967 | expect(output).toEqual({
968 | a: "",
969 | });
970 | });
971 |
972 | it("should correctly flatten mixed structures involving arrays and objects", () => {
973 | const input = {
974 | a: {
975 | b: ["value1", { nested: "value2" }, "value3"],
976 | },
977 | };
978 |
979 | const output = flattenJSON(input);
980 |
981 | expect(output).toEqual({
982 | "a.b.0": "value1",
983 | "a.b.1.nested": "value2",
984 | "a.b.2": "value3",
985 | });
986 | });
987 |
988 | it("should flatten deeply nested objects", () => {
989 | const input = {
990 | level1: {
991 | level2: {
992 | level3: {
993 | key: "value",
994 | anotherKey: "anotherValue",
995 | },
996 | },
997 | singleKey: "test",
998 | },
999 | };
1000 |
1001 | const output = flattenJSON(input);
1002 |
1003 | expect(output).toEqual({
1004 | "level1.level2.level3.key": "value",
1005 | "level1.level2.level3.anotherKey": "anotherValue",
1006 | "level1.singleKey": "test",
1007 | });
1008 | });
1009 |
1010 | it("should handle objects with empty values", () => {
1011 | const input = {
1012 | a: {
1013 | b: "",
1014 | },
1015 | };
1016 |
1017 | const output = flattenJSON(input);
1018 |
1019 | expect(output).toEqual({
1020 | "a.b": "",
1021 | });
1022 | });
1023 |
1024 | it("should handle null values", () => {
1025 | const input = {
1026 | a: null,
1027 | b: {
1028 | c: null,
1029 | },
1030 | };
1031 |
1032 | const output = flattenJSON(input);
1033 |
1034 | expect(output).toEqual({
1035 | a: "",
1036 | "b.c": "",
1037 | });
1038 | });
1039 |
1040 | it("should skip undefined values", () => {
1041 | const input = {
1042 | a: "value",
1043 | b: undefined,
1044 | c: {
1045 | d: undefined,
1046 | e: "another value",
1047 | },
1048 | };
1049 |
1050 | const output = flattenJSON(input);
1051 |
1052 | expect(output).toEqual({
1053 | a: "value",
1054 | "c.e": "another value",
1055 | });
1056 | });
1057 |
1058 | it("should handle empty nested objects", () => {
1059 | const input = {
1060 | a: {},
1061 | b: {
1062 | c: {},
1063 | d: "value",
1064 | },
1065 | };
1066 |
1067 | const output = flattenJSON(input);
1068 |
1069 | expect(output).toEqual({
1070 | a: "",
1071 | "b.c": "",
1072 | "b.d": "value",
1073 | });
1074 | });
1075 |
1076 | it("should handle top-level primitive values", () => {
1077 | const input = {
1078 | a: "simple",
1079 | b: 42,
1080 | c: true,
1081 | d: false,
1082 | };
1083 |
1084 | const output = flattenJSON(input);
1085 |
1086 | expect(output).toEqual({
1087 | a: "simple",
1088 | b: "42",
1089 | c: "true",
1090 | d: "false",
1091 | });
1092 | });
1093 |
1094 | it("should handle arrays with null and undefined values", () => {
1095 | const input = {
1096 | a: ["value1", null, undefined, "value4"],
1097 | };
1098 |
1099 | const output = flattenJSON(input);
1100 |
1101 | expect(output).toEqual({
1102 | "a.0": "value1",
1103 | "a.1": "",
1104 | "a.3": "value4",
1105 | });
1106 | });
1107 |
1108 | it("should handle deeply nested empty structures", () => {
1109 | const input = {
1110 | a: {
1111 | b: {
1112 | c: {},
1113 | d: [],
1114 | },
1115 | },
1116 | };
1117 |
1118 | const output = flattenJSON(input);
1119 |
1120 | expect(output).toEqual({
1121 | "a.b.c": "",
1122 | "a.b.d": "",
1123 | });
1124 | });
1125 |
1126 | it("should handle keys with special characters", () => {
1127 | const input = {
1128 | "key.with.dots": "value1",
1129 | "key-with-dashes": "value2",
1130 | "key with spaces": "value3",
1131 | };
1132 |
1133 | const output = flattenJSON(input);
1134 |
1135 | expect(output).toEqual({
1136 | "key.with.dots": "value1",
1137 | "key-with-dashes": "value2",
1138 | "key with spaces": "value3",
1139 | });
1140 | });
1141 |
1142 | it("should handle edge case numbers and booleans", () => {
1143 | const input = {
1144 | zero: 0,
1145 | negativeNumber: -42,
1146 | float: 3.14,
1147 | infinity: Infinity,
1148 | negativeInfinity: -Infinity,
1149 | nan: NaN,
1150 | falseValue: false,
1151 | };
1152 |
1153 | const output = flattenJSON(input);
1154 |
1155 | expect(output).toEqual({
1156 | zero: "0",
1157 | negativeNumber: "-42",
1158 | float: "3.14",
1159 | infinity: "Infinity",
1160 | negativeInfinity: "-Infinity",
1161 | nan: "NaN",
1162 | falseValue: "false",
1163 | });
1164 | });
1165 | });
1166 |
1167 | describe("unflattenJSON", () => {
1168 | it("should handle an empty object correctly", () => {
1169 | const input = {};
1170 | const output = unflattenJSON(input);
1171 |
1172 | expect(output).toEqual({});
1173 | });
1174 |
1175 | it("should convert a flat object with one level deep keys to a nested object", () => {
1176 | const input = {
1177 | "a.b.c": "value",
1178 | "x.y": "anotherValue",
1179 | };
1180 |
1181 | const output = unflattenJSON(input);
1182 |
1183 | expect(output).toEqual({
1184 | a: {
1185 | b: { c: "value" },
1186 | },
1187 | x: {
1188 | y: "anotherValue",
1189 | },
1190 | });
1191 | });
1192 |
1193 | it("should not handle arrays properly", () => {
1194 | const input = {
1195 | "arr.0": "first",
1196 | "arr.1": "second",
1197 | "arr.2": "third",
1198 | };
1199 |
1200 | const output = unflattenJSON(input);
1201 |
1202 | expect(output).toEqual({
1203 | arr: {
1204 | "0": "first",
1205 | "1": "second",
1206 | "2": "third",
1207 | },
1208 | });
1209 | });
1210 |
1211 | it("should handle mixed data types in flat JSON", () => {
1212 | const input = {
1213 | "a.b": "string",
1214 | "a.c": 123,
1215 | "a.d": true,
1216 | };
1217 |
1218 | const output = unflattenJSON(input);
1219 |
1220 | expect(output).toEqual({
1221 | a: {
1222 | b: "string",
1223 | c: 123,
1224 | d: true,
1225 | },
1226 | });
1227 | });
1228 |
1229 | it("should correctly handle scenarios with overlapping keys (ignore)", () => {
1230 | const input = {
1231 | "a.b": "value1",
1232 | "a.b.c": "value2",
1233 | };
1234 |
1235 | const output = unflattenJSON(input);
1236 | expect(output).toEqual({ a: { b: "value1" } });
1237 | });
1238 |
1239 | it("should unflatten nested objects correctly", () => {
1240 | const input = {
1241 | "level1.level2.level3": "deepValue",
1242 | "level1.level2.key": 10,
1243 | "level1.singleKey": "test",
1244 | };
1245 |
1246 | const output = unflattenJSON(input);
1247 |
1248 | expect(output).toEqual({
1249 | level1: {
1250 | level2: {
1251 | level3: "deepValue",
1252 | key: 10,
1253 | },
1254 | singleKey: "test",
1255 | },
1256 | });
1257 | });
1258 |
1259 | it("should handle a scenario where a key is an empty string", () => {
1260 | const input = {
1261 | "": "rootValue",
1262 | };
1263 |
1264 | const output = unflattenJSON(input);
1265 |
1266 | expect(output).toEqual({
1267 | "": "rootValue",
1268 | });
1269 | });
1270 | });
1271 |
```
--------------------------------------------------------------------------------
/packages/react-sdk/test/usage.test.tsx:
--------------------------------------------------------------------------------
```typescript
1 | import React from "react";
2 | import { render, renderHook, waitFor } from "@testing-library/react";
3 | import { http, HttpResponse } from "msw";
4 | import { setupServer } from "msw/node";
5 | import {
6 | afterAll,
7 | afterEach,
8 | beforeAll,
9 | beforeEach,
10 | describe,
11 | expect,
12 | test,
13 | vi,
14 | } from "vitest";
15 |
16 | import { ReflagClient } from "@reflag/browser-sdk";
17 |
18 | import {
19 | BootstrappedFlags,
20 | ReflagBootstrappedProps,
21 | ReflagBootstrappedProvider,
22 | ReflagClientProvider,
23 | ReflagProps,
24 | ReflagProvider,
25 | useClient,
26 | useFlag,
27 | useIsLoading,
28 | useOnEvent,
29 | useRequestFeedback,
30 | useSendFeedback,
31 | useTrack,
32 | useUpdateCompany,
33 | useUpdateOtherContext,
34 | useUpdateUser,
35 | } from "../src";
36 |
37 | const events: string[] = [];
38 | const originalConsoleError = console.error.bind(console);
39 |
40 | afterEach(() => {
41 | events.length = 0;
42 | console.error = originalConsoleError;
43 | });
44 |
45 | const company = { id: "123", name: "test" };
46 | const user = { id: "456", name: "test" };
47 | const other = { test: "test" };
48 |
49 | let keyIndex = 0;
50 |
51 | function getProvider(props: Omit<Partial<ReflagProps>, "publishableKey"> = {}) {
52 | const publishableKey = `KEY-${keyIndex++}`;
53 | return (
54 | <ReflagProvider
55 | context={{ user, company, other }}
56 | publishableKey={publishableKey}
57 | {...props}
58 | />
59 | );
60 | }
61 |
62 | function getBootstrapProvider(
63 | bootstrapFlags: BootstrappedFlags,
64 | props: Omit<Partial<ReflagBootstrappedProps>, "publishableKey"> = {},
65 | ) {
66 | const publishableKey = `KEY-${keyIndex++}`;
67 | return (
68 | <ReflagBootstrappedProvider
69 | flags={bootstrapFlags}
70 | publishableKey={publishableKey}
71 | {...props}
72 | />
73 | );
74 | }
75 |
76 | const server = setupServer(
77 | http.post(/\/event$/, () => {
78 | events.push("EVENT");
79 | return new HttpResponse(
80 | JSON.stringify({
81 | success: true,
82 | }),
83 | { status: 200 },
84 | );
85 | }),
86 | http.post(/\/feedback$/, () => {
87 | events.push("FEEDBACK");
88 | return new HttpResponse(
89 | JSON.stringify({
90 | success: true,
91 | }),
92 | { status: 200 },
93 | );
94 | }),
95 | http.get(/\/features\/evaluated$/, () => {
96 | return new HttpResponse(
97 | JSON.stringify({
98 | success: true,
99 | features: {
100 | abc: {
101 | key: "abc",
102 | isEnabled: true,
103 | targetingVersion: 1,
104 | config: {
105 | key: "gpt3",
106 | payload: { model: "gpt-something", temperature: 0.5 },
107 | version: 2,
108 | },
109 | },
110 | def: {
111 | key: "def",
112 | isEnabled: true,
113 | targetingVersion: 2,
114 | },
115 | },
116 | }),
117 | { status: 200 },
118 | );
119 | }),
120 | http.post(/\/user$/, () => {
121 | return new HttpResponse(
122 | JSON.stringify({
123 | success: true,
124 | }),
125 | { status: 200 },
126 | );
127 | }),
128 | http.post(/\/company$/, () => {
129 | return new HttpResponse(
130 | JSON.stringify({
131 | success: true,
132 | }),
133 | { status: 200 },
134 | );
135 | }),
136 | http.post(/feedback\/prompting-init$/, () => {
137 | return new HttpResponse(
138 | JSON.stringify({
139 | success: false,
140 | }),
141 | { status: 200 },
142 | );
143 | }),
144 | http.post(/\/features\/events$/, () => {
145 | return new HttpResponse(
146 | JSON.stringify({
147 | success: false,
148 | }),
149 | { status: 200 },
150 | );
151 | }),
152 | );
153 |
154 | beforeAll(() =>
155 | server.listen({
156 | onUnhandledRequest(request) {
157 | console.error("Unhandled %s %s", request.method, request.url);
158 | },
159 | }),
160 | );
161 |
162 | afterEach(() => server.resetHandlers());
163 | afterAll(() => server.close());
164 |
165 | beforeAll(() => {
166 | vi.spyOn(ReflagClient.prototype, "initialize");
167 | vi.spyOn(ReflagClient.prototype, "stop");
168 | });
169 |
170 | beforeEach(() => {
171 | vi.clearAllMocks();
172 | });
173 |
174 | describe("<ReflagProvider />", () => {
175 | test("calls initialize", () => {
176 | const initialize = vi.spyOn(ReflagClient.prototype, "initialize");
177 |
178 | const provider = getProvider({
179 | apiBaseUrl: "https://apibaseurl.com",
180 | sseBaseUrl: "https://ssebaseurl.com",
181 | context: {
182 | user: { id: "456", name: "test" },
183 | company: { id: "123", name: "test" },
184 | other: { test: "test" },
185 | },
186 | enableTracking: false,
187 | appBaseUrl: "https://appbaseurl.com",
188 | staleTimeMs: 1001,
189 | timeoutMs: 1002,
190 | expireTimeMs: 1003,
191 | staleWhileRevalidate: true,
192 | fallbackFlags: ["flag2"],
193 | feedback: { enableAutoFeedback: true },
194 | toolbar: { show: true },
195 | });
196 |
197 | render(provider);
198 |
199 | expect(initialize).toHaveBeenCalled();
200 | });
201 |
202 | test("only calls init once with the same args", () => {
203 | const node = getProvider();
204 | const initialize = vi.spyOn(ReflagClient.prototype, "initialize");
205 |
206 | const x = render(node);
207 | x.rerender(node);
208 | x.rerender(node);
209 | x.rerender(node);
210 |
211 | expect(initialize).toHaveBeenCalledOnce();
212 | expect(ReflagClient.prototype.stop).not.toHaveBeenCalledOnce();
213 | });
214 |
215 | test("handles context changes", async () => {
216 | const { queryByTestId, rerender } = render(
217 | getProvider({
218 | loadingComponent: <span data-testid="loading">Loading...</span>,
219 | children: <span data-testid="content">Content</span>,
220 | }),
221 | );
222 |
223 | // Wait for content to be visible
224 | await waitFor(() => {
225 | expect(queryByTestId("content")).not.toBeNull();
226 | });
227 |
228 | // Change user context
229 | rerender(
230 | getProvider({
231 | loadingComponent: <span data-testid="loading">Loading...</span>,
232 | user: { ...user, id: "new-user-id" },
233 | children: <span data-testid="content">Content</span>,
234 | }),
235 | );
236 |
237 | // Content should still be visible
238 | await waitFor(() => {
239 | expect(queryByTestId("content")).not.toBeNull();
240 | });
241 |
242 | // Change company context
243 | rerender(
244 | getProvider({
245 | loadingComponent: <span data-testid="loading">Loading...</span>,
246 | company: { ...company, id: "new-company-id" },
247 | children: <span data-testid="content">Content</span>,
248 | }),
249 | );
250 |
251 | // Content should still be visible
252 | await waitFor(() => {
253 | expect(queryByTestId("content")).not.toBeNull();
254 | });
255 | });
256 | });
257 |
258 | describe("useFlag", () => {
259 | test("returns a loading state initially", async () => {
260 | const { result, unmount } = renderHook(() => useFlag("huddle"), {
261 | wrapper: ({ children }) => getProvider({ children }),
262 | });
263 |
264 | // The flag should exist but may be loading or not depending on implementation
265 | expect(result.current.key).toBe("huddle");
266 | expect(result.current.isEnabled).toBe(false);
267 | expect(result.current.config).toEqual({
268 | key: undefined,
269 | payload: undefined,
270 | });
271 | expect(typeof result.current.track).toBe("function");
272 | expect(typeof result.current.requestFeedback).toBe("function");
273 |
274 | unmount();
275 | });
276 |
277 | test("finishes loading", async () => {
278 | const { result, unmount } = renderHook(() => useFlag("huddle"), {
279 | wrapper: ({ children }) => getProvider({ children }),
280 | });
281 |
282 | await waitFor(() => {
283 | expect(result.current).toStrictEqual({
284 | key: "huddle",
285 | config: { key: undefined, payload: undefined },
286 | isEnabled: false,
287 | isLoading: false,
288 | track: expect.any(Function),
289 | requestFeedback: expect.any(Function),
290 | });
291 | });
292 |
293 | unmount();
294 | });
295 |
296 | test("provides the expected values if flag is enabled", async () => {
297 | const { result, unmount } = renderHook(() => useFlag("abc"), {
298 | wrapper: ({ children }) => getProvider({ children }),
299 | });
300 |
301 | await waitFor(() => {
302 | expect(result.current).toStrictEqual({
303 | key: "abc",
304 | isEnabled: true,
305 | isLoading: false,
306 | config: {
307 | key: "gpt3",
308 | payload: { model: "gpt-something", temperature: 0.5 },
309 | },
310 | track: expect.any(Function),
311 | requestFeedback: expect.any(Function),
312 | });
313 | });
314 |
315 | unmount();
316 | });
317 | });
318 |
319 | describe("useTrack", () => {
320 | test("sends track request", async () => {
321 | const { result, unmount } = renderHook(() => useTrack(), {
322 | wrapper: ({ children }) => getProvider({ children }),
323 | });
324 |
325 | await waitFor(async () => {
326 | await result.current("event", { test: "test" });
327 | expect(events).toStrictEqual(["EVENT"]);
328 | });
329 |
330 | unmount();
331 | });
332 | });
333 |
334 | describe("useSendFeedback", () => {
335 | test("sends feedback", async () => {
336 | const { result, unmount } = renderHook(() => useSendFeedback(), {
337 | wrapper: ({ children }) => getProvider({ children }),
338 | });
339 |
340 | await waitFor(async () => {
341 | await result.current({
342 | flagKey: "huddles",
343 | score: 5,
344 | });
345 | expect(events).toStrictEqual(["FEEDBACK"]);
346 | });
347 |
348 | unmount();
349 | });
350 | });
351 |
352 | describe("useRequestFeedback", () => {
353 | test("sends feedback", async () => {
354 | const requestFeedback = vi
355 | .spyOn(ReflagClient.prototype, "requestFeedback")
356 | .mockReturnValue(undefined);
357 |
358 | const { result, unmount } = renderHook(() => useRequestFeedback(), {
359 | wrapper: ({ children }) => getProvider({ children }),
360 | });
361 |
362 | await waitFor(async () => {
363 | result.current({
364 | flagKey: "huddles",
365 | title: "Test question",
366 | companyId: "456",
367 | });
368 |
369 | expect(requestFeedback).toHaveBeenCalledOnce();
370 | expect(requestFeedback).toHaveBeenCalledWith({
371 | flagKey: "huddles",
372 | companyId: "456",
373 | title: "Test question",
374 | });
375 | });
376 |
377 | unmount();
378 | });
379 | });
380 |
381 | describe("useUpdateUser", () => {
382 | test("updates user", async () => {
383 | const updateUser = vi
384 | .spyOn(ReflagClient.prototype, "updateUser")
385 | .mockResolvedValue(undefined);
386 |
387 | const { result: updateUserFn, unmount } = renderHook(
388 | () => useUpdateUser(),
389 | {
390 | wrapper: ({ children }) => getProvider({ children }),
391 | },
392 | );
393 |
394 | // todo: need this `waitFor` because useUpdateOtherContext
395 | // runs before `client` is initialized and then the call gets
396 | // lost.
397 | await waitFor(async () => {
398 | await updateUserFn.current({ optInHuddles: "true" });
399 |
400 | expect(updateUser).toHaveBeenCalledWith({
401 | optInHuddles: "true",
402 | });
403 | });
404 |
405 | unmount();
406 | });
407 | });
408 |
409 | describe("useUpdateCompany", () => {
410 | test("updates company", async () => {
411 | const updateCompany = vi
412 | .spyOn(ReflagClient.prototype, "updateCompany")
413 | .mockResolvedValue(undefined);
414 |
415 | const { result: updateCompanyFn, unmount } = renderHook(
416 | () => useUpdateCompany(),
417 | {
418 | wrapper: ({ children }) => getProvider({ children }),
419 | },
420 | );
421 |
422 | // todo: need this `waitFor` because useUpdateOtherContext
423 | // runs before `client` is initialized and then the call gets
424 | // lost.
425 | await waitFor(async () => {
426 | await updateCompanyFn.current({ optInHuddles: "true" });
427 |
428 | expect(updateCompany).toHaveBeenCalledWith({
429 | optInHuddles: "true",
430 | });
431 | });
432 | unmount();
433 | });
434 | });
435 |
436 | describe("useUpdateOtherContext", () => {
437 | test("updates other context", async () => {
438 | const updateOtherContext = vi
439 | .spyOn(ReflagClient.prototype, "updateOtherContext")
440 | .mockResolvedValue(undefined);
441 |
442 | const { result: updateOtherContextFn, unmount } = renderHook(
443 | () => useUpdateOtherContext(),
444 | {
445 | wrapper: ({ children }) => getProvider({ children }),
446 | },
447 | );
448 |
449 | // todo: need this `waitFor` because useUpdateOtherContext
450 | // runs before `client` is initialized and then the call gets
451 | // lost.
452 | await waitFor(async () => {
453 | await updateOtherContextFn.current({ optInHuddles: "true" });
454 |
455 | expect(updateOtherContext).toHaveBeenCalledWith({
456 | optInHuddles: "true",
457 | });
458 | });
459 |
460 | unmount();
461 | });
462 | });
463 |
464 | describe("useClient", () => {
465 | test("gets the client", async () => {
466 | const { result: clientFn, unmount } = renderHook(() => useClient(), {
467 | wrapper: ({ children }) => getProvider({ children }),
468 | });
469 |
470 | await waitFor(async () => {
471 | expect(clientFn.current).toBeDefined();
472 | });
473 |
474 | unmount();
475 | });
476 | });
477 |
478 | describe("<ReflagBootstrappedProvider />", () => {
479 | test("renders with pre-fetched flags", () => {
480 | const bootstrapFlags: BootstrappedFlags = {
481 | context: {
482 | user: { id: "456", name: "test" },
483 | company: { id: "123", name: "test" },
484 | other: { test: "test" },
485 | },
486 | flags: {
487 | abc: {
488 | key: "abc",
489 | isEnabled: true,
490 | targetingVersion: 1,
491 | config: {
492 | key: "gpt3",
493 | payload: { model: "gpt-something", temperature: 0.5 },
494 | version: 2,
495 | },
496 | },
497 | def: {
498 | key: "def",
499 | isEnabled: true,
500 | targetingVersion: 2,
501 | },
502 | },
503 | };
504 |
505 | const { container } = render(
506 | getBootstrapProvider(bootstrapFlags, {
507 | apiBaseUrl: "https://apibaseurl.com",
508 | sseBaseUrl: "https://ssebaseurl.com",
509 | enableTracking: false,
510 | appBaseUrl: "https://appbaseurl.com",
511 | staleTimeMs: 1001,
512 | timeoutMs: 1002,
513 | expireTimeMs: 1003,
514 | staleWhileRevalidate: true,
515 | fallbackFlags: ["flag2"],
516 | feedback: { enableAutoFeedback: true },
517 | toolbar: { show: true },
518 | children: <span>Test Content</span>,
519 | }),
520 | );
521 |
522 | expect(container).toBeDefined();
523 | });
524 |
525 | test("renders in bootstrap mode", () => {
526 | const bootstrapFlags: BootstrappedFlags = {
527 | context: {
528 | user: { id: "456", name: "test" },
529 | company: { id: "123", name: "test" },
530 | other: { test: "test" },
531 | },
532 | flags: {
533 | abc: {
534 | key: "abc",
535 | isEnabled: true,
536 | targetingVersion: 1,
537 | },
538 | },
539 | };
540 |
541 | const { container } = render(
542 | getBootstrapProvider(bootstrapFlags, {
543 | children: <span>Bootstrap Content</span>,
544 | }),
545 | );
546 |
547 | expect(container).toBeDefined();
548 | });
549 |
550 | // Removed test "does not initialize when no flags are provided"
551 | // because ReflagBootstrappedProvider requires flags to be provided
552 |
553 | test("shows content after initialization", async () => {
554 | const bootstrapFlags: BootstrappedFlags = {
555 | context: {
556 | user: { id: "456", name: "test" },
557 | company: { id: "123", name: "test" },
558 | other: { test: "test" },
559 | },
560 | flags: {
561 | abc: {
562 | key: "abc",
563 | isEnabled: true,
564 | targetingVersion: 1,
565 | },
566 | },
567 | };
568 |
569 | const { container } = render(
570 | getBootstrapProvider(bootstrapFlags, {
571 | loadingComponent: <span data-testid="loading">Loading...</span>,
572 | children: <span data-testid="bootstrap-content">Content</span>,
573 | }),
574 | );
575 |
576 | // Content should eventually be visible
577 | await waitFor(() => {
578 | expect(
579 | container.querySelector('[data-testid="bootstrap-content"]'),
580 | ).not.toBeNull();
581 | });
582 | });
583 |
584 | // Removed test "shows loading component when no flags are provided"
585 | // because ReflagBootstrappedProvider requires flags to be provided
586 | });
587 |
588 | describe("useFlag with ReflagBootstrappedProvider", () => {
589 | test("returns bootstrapped flag values", async () => {
590 | const bootstrapFlags: BootstrappedFlags = {
591 | context: {
592 | user: { id: "456", name: "test" },
593 | company: { id: "123", name: "test" },
594 | other: { test: "test" },
595 | },
596 | flags: {
597 | abc: {
598 | key: "abc",
599 | isEnabled: true,
600 | targetingVersion: 1,
601 | config: {
602 | key: "gpt3",
603 | payload: { model: "gpt-something", temperature: 0.5 },
604 | version: 2,
605 | },
606 | },
607 | def: {
608 | key: "def",
609 | isEnabled: true,
610 | targetingVersion: 2,
611 | },
612 | },
613 | };
614 |
615 | const { result, unmount } = renderHook(() => useFlag("abc"), {
616 | wrapper: ({ children }) =>
617 | getBootstrapProvider(bootstrapFlags, { children }),
618 | });
619 |
620 | await waitFor(() => {
621 | expect(result.current).toStrictEqual({
622 | key: "abc",
623 | isEnabled: true,
624 | isLoading: false,
625 | config: {
626 | key: "gpt3",
627 | payload: { model: "gpt-something", temperature: 0.5 },
628 | },
629 | track: expect.any(Function),
630 | requestFeedback: expect.any(Function),
631 | });
632 | });
633 |
634 | unmount();
635 | });
636 |
637 | test("returns disabled flag for non-existent flags", async () => {
638 | const bootstrapFlags: BootstrappedFlags = {
639 | context: {
640 | user: { id: "456", name: "test" },
641 | company: { id: "123", name: "test" },
642 | other: { test: "test" },
643 | },
644 | flags: {
645 | abc: {
646 | key: "abc",
647 | isEnabled: true,
648 | targetingVersion: 1,
649 | },
650 | },
651 | };
652 |
653 | const { result, unmount } = renderHook(() => useFlag("nonexistent"), {
654 | wrapper: ({ children }) =>
655 | getBootstrapProvider(bootstrapFlags, { children }),
656 | });
657 |
658 | await waitFor(() => {
659 | expect(result.current).toStrictEqual({
660 | key: "nonexistent",
661 | isEnabled: false,
662 | isLoading: false,
663 | config: {
664 | key: undefined,
665 | payload: undefined,
666 | },
667 | track: expect.any(Function),
668 | requestFeedback: expect.any(Function),
669 | });
670 | });
671 |
672 | unmount();
673 | });
674 |
675 | // Removed test "returns loading state when no flags are bootstrapped"
676 | // because ReflagBootstrappedProvider requires flags to be provided
677 | });
678 |
679 | describe("<ReflagClientProvider />", () => {
680 | test("renders with external client and optional loadingComponent", async () => {
681 | const client = new ReflagClient({
682 | publishableKey: "test-key",
683 | user,
684 | company,
685 | other,
686 | });
687 |
688 | const { container } = render(
689 | <ReflagClientProvider client={client}>
690 | <span data-testid="content">Test Content</span>
691 | </ReflagClientProvider>,
692 | );
693 |
694 | expect(container.querySelector('[data-testid="content"]')).not.toBeNull();
695 | });
696 |
697 | test("renders with external client and loadingComponent", async () => {
698 | const client = new ReflagClient({
699 | publishableKey: "test-key",
700 | user,
701 | company,
702 | other,
703 | });
704 |
705 | const { container } = render(
706 | <ReflagClientProvider
707 | client={client}
708 | loadingComponent={<span data-testid="loading">Loading...</span>}
709 | >
710 | <span data-testid="content">Test Content</span>
711 | </ReflagClientProvider>,
712 | );
713 |
714 | // Initially may show loading or content depending on client state
715 | expect(container).toBeDefined();
716 | });
717 |
718 | test("provides client to child components", async () => {
719 | const client = new ReflagClient({
720 | publishableKey: "test-key",
721 | user,
722 | company,
723 | other,
724 | });
725 |
726 | const { result, unmount } = renderHook(() => useClient(), {
727 | wrapper: ({ children }) => (
728 | <ReflagClientProvider client={client}>{children}</ReflagClientProvider>
729 | ),
730 | });
731 |
732 | expect(result.current).toBe(client);
733 |
734 | // Verify that the external client maintains its context
735 | const context = result.current.getContext();
736 | expect(context.user).toEqual(user);
737 | expect(context.company).toEqual(company);
738 | expect(context.other).toEqual(other);
739 |
740 | unmount();
741 | });
742 |
743 | test("handles client state changes", async () => {
744 | const client = new ReflagClient({
745 | publishableKey: "test-key-state-changes",
746 | user,
747 | company,
748 | other,
749 | });
750 |
751 | const { container } = render(
752 | <ReflagClientProvider
753 | client={client}
754 | loadingComponent={<span data-testid="client-loading">Loading...</span>}
755 | >
756 | <span data-testid="client-content">Content</span>
757 | </ReflagClientProvider>,
758 | );
759 |
760 | // The component should handle state changes properly
761 | expect(
762 | container.querySelector('[data-testid="client-content"]') ||
763 | container.querySelector('[data-testid="client-loading"]'),
764 | ).not.toBeNull();
765 | });
766 |
767 | test("works with useFlag hook", async () => {
768 | const client = new ReflagClient({
769 | publishableKey: "test-key",
770 | user,
771 | company,
772 | other,
773 | });
774 |
775 | const { result, unmount } = renderHook(() => useFlag("test-flag"), {
776 | wrapper: ({ children }) => (
777 | <ReflagClientProvider client={client}>{children}</ReflagClientProvider>
778 | ),
779 | });
780 |
781 | expect(result.current.key).toBe("test-flag");
782 | expect(typeof result.current.track).toBe("function");
783 | expect(typeof result.current.requestFeedback).toBe("function");
784 |
785 | unmount();
786 | });
787 | });
788 |
789 | describe("ReflagProvider with deprecated properties", () => {
790 | test("works with deprecated user property", async () => {
791 | const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
792 | const { result, unmount } = renderHook(() => useClient(), {
793 | wrapper: ({ children }) => (
794 | <ReflagProvider
795 | context={{}}
796 | publishableKey="test-key-1"
797 | user={deprecatedUser}
798 | >
799 | {children}
800 | </ReflagProvider>
801 | ),
802 | });
803 |
804 | await waitFor(() => {
805 | expect(result.current).toBeDefined();
806 | const context = result.current.getContext();
807 | expect(context.user).toEqual(deprecatedUser);
808 | expect(context.company).toBeUndefined();
809 | expect(context.other).toEqual({});
810 | });
811 |
812 | unmount();
813 | });
814 |
815 | test("works with deprecated company property", async () => {
816 | const deprecatedCompany = {
817 | id: "deprecated-company",
818 | name: "Deprecated Company",
819 | };
820 | const { result, unmount } = renderHook(() => useClient(), {
821 | wrapper: ({ children }) => (
822 | <ReflagProvider
823 | company={deprecatedCompany}
824 | context={{}}
825 | publishableKey="test-key-2"
826 | >
827 | {children}
828 | </ReflagProvider>
829 | ),
830 | });
831 |
832 | await waitFor(() => {
833 | expect(result.current).toBeDefined();
834 | const context = result.current.getContext();
835 | expect(context.company).toEqual(deprecatedCompany);
836 | expect(context.user).toBeUndefined();
837 | expect(context.other).toEqual({});
838 | });
839 |
840 | unmount();
841 | });
842 |
843 | test("works with deprecated otherContext property", async () => {
844 | const deprecatedOtherContext = { workspace: "deprecated-workspace" };
845 | const { result, unmount } = renderHook(() => useClient(), {
846 | wrapper: ({ children }) => (
847 | <ReflagProvider
848 | context={{}}
849 | otherContext={deprecatedOtherContext}
850 | publishableKey="test-key-3"
851 | >
852 | {children}
853 | </ReflagProvider>
854 | ),
855 | });
856 |
857 | await waitFor(() => {
858 | expect(result.current).toBeDefined();
859 | const context = result.current.getContext();
860 | expect(context.other).toEqual(deprecatedOtherContext);
861 | expect(context.user).toBeUndefined();
862 | expect(context.company).toBeUndefined();
863 | });
864 |
865 | unmount();
866 | });
867 |
868 | test("context property overrides deprecated properties", async () => {
869 | const contextUser = { id: "context-user", name: "Context User" };
870 | const contextCompany = { id: "context-company", name: "Context Company" };
871 | const contextOther = { workspace: "context-workspace" };
872 |
873 | const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
874 | const deprecatedCompany = {
875 | id: "deprecated-company",
876 | name: "Deprecated Company",
877 | };
878 | const deprecatedOtherContext = { workspace: "deprecated-workspace" };
879 |
880 | const { result, unmount } = renderHook(() => useClient(), {
881 | wrapper: ({ children }) => (
882 | <ReflagProvider
883 | company={deprecatedCompany}
884 | context={{
885 | user: contextUser,
886 | company: contextCompany,
887 | other: contextOther,
888 | }}
889 | otherContext={deprecatedOtherContext}
890 | publishableKey="test-key-4"
891 | user={deprecatedUser}
892 | >
893 | {children}
894 | </ReflagProvider>
895 | ),
896 | });
897 |
898 | await waitFor(() => {
899 | expect(result.current).toBeDefined();
900 | const context = result.current.getContext();
901 | // The context property should override deprecated properties
902 | expect(context.user).toEqual(contextUser);
903 | expect(context.company).toEqual(contextCompany);
904 | expect(context.other).toEqual(contextOther);
905 | });
906 |
907 | unmount();
908 | });
909 |
910 | test("merges deprecated properties with context", async () => {
911 | const contextUser = { id: "context-user", email: "[email protected]" };
912 | const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
913 | const deprecatedCompany = {
914 | id: "deprecated-company",
915 | name: "Deprecated Company",
916 | };
917 |
918 | const { result, unmount } = renderHook(() => useClient(), {
919 | wrapper: ({ children }) => (
920 | <ReflagProvider
921 | company={deprecatedCompany}
922 | context={{
923 | user: contextUser,
924 | }}
925 | publishableKey="test-key-5"
926 | user={deprecatedUser}
927 | >
928 | {children}
929 | </ReflagProvider>
930 | ),
931 | });
932 |
933 | await waitFor(() => {
934 | expect(result.current).toBeDefined();
935 | const context = result.current.getContext();
936 | // The context user should override the deprecated user,
937 | // but deprecated company should still be present
938 | expect(context.user).toEqual(contextUser);
939 | expect(context.company).toEqual(deprecatedCompany);
940 | expect(context.other).toEqual({});
941 | });
942 |
943 | unmount();
944 | });
945 |
946 | test("handles all deprecated properties together", async () => {
947 | const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
948 | const deprecatedCompany = {
949 | id: "deprecated-company",
950 | name: "Deprecated Company",
951 | };
952 | const deprecatedOtherContext = {
953 | workspace: "deprecated-workspace",
954 | feature: "test",
955 | };
956 |
957 | const { result, unmount } = renderHook(() => useClient(), {
958 | wrapper: ({ children }) => (
959 | <ReflagProvider
960 | company={deprecatedCompany}
961 | context={{}}
962 | otherContext={deprecatedOtherContext}
963 | publishableKey="test-key-6"
964 | user={deprecatedUser}
965 | >
966 | {children}
967 | </ReflagProvider>
968 | ),
969 | });
970 |
971 | await waitFor(() => {
972 | expect(result.current).toBeDefined();
973 | const context = result.current.getContext();
974 | // All deprecated properties should be properly set
975 | expect(context.user).toEqual(deprecatedUser);
976 | expect(context.company).toEqual(deprecatedCompany);
977 | expect(context.other).toEqual(deprecatedOtherContext);
978 | });
979 |
980 | unmount();
981 | });
982 | });
983 |
984 | describe("useIsLoading", () => {
985 | test("returns loading state during initialization", async () => {
986 | const { result, unmount } = renderHook(() => useIsLoading(), {
987 | wrapper: ({ children }) => getProvider({ children }),
988 | });
989 |
990 | // Should be loading initially
991 | expect(result.current).toBe(true);
992 |
993 | // Wait for initialization to complete
994 | await waitFor(() => {
995 | expect(result.current).toBe(false);
996 | });
997 |
998 | unmount();
999 | });
1000 |
1001 | test("throws error when used outside provider", () => {
1002 | const consoleErrorSpy = vi
1003 | .spyOn(console, "error")
1004 | .mockImplementation(() => {
1005 | // Silence console.error during test
1006 | });
1007 |
1008 | expect(() => {
1009 | renderHook(() => useIsLoading());
1010 | }).toThrow(
1011 | "ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.",
1012 | );
1013 |
1014 | consoleErrorSpy.mockRestore();
1015 | });
1016 | });
1017 |
1018 | describe("useOnEvent", () => {
1019 | test("subscribes to flagsUpdated event", async () => {
1020 | const eventHandler = vi.fn();
1021 | const client = new ReflagClient({
1022 | publishableKey: "test-key-events",
1023 | user,
1024 | company,
1025 | other,
1026 | });
1027 |
1028 | const { unmount } = renderHook(
1029 | () => useOnEvent("flagsUpdated", eventHandler),
1030 | {
1031 | wrapper: ({ children }) => (
1032 | <ReflagClientProvider client={client}>
1033 | {children}
1034 | </ReflagClientProvider>
1035 | ),
1036 | },
1037 | );
1038 |
1039 | // Initialize the client to trigger events
1040 | await client.initialize();
1041 |
1042 | // Wait for the event to be triggered
1043 | await waitFor(() => {
1044 | expect(eventHandler).toHaveBeenCalled();
1045 | });
1046 |
1047 | unmount();
1048 | });
1049 |
1050 | test("works with external client parameter", async () => {
1051 | const eventHandler = vi.fn();
1052 | const client = new ReflagClient({
1053 | publishableKey: "test-key-external",
1054 | user,
1055 | company,
1056 | other,
1057 | });
1058 |
1059 | const { unmount } = renderHook(() =>
1060 | useOnEvent("flagsUpdated", eventHandler, client),
1061 | );
1062 |
1063 | // Initialize the client to trigger events
1064 | await client.initialize();
1065 |
1066 | // Wait for the event to be triggered
1067 | await waitFor(() => {
1068 | expect(eventHandler).toHaveBeenCalled();
1069 | });
1070 |
1071 | unmount();
1072 | });
1073 |
1074 | test("cleans up event listeners on unmount", async () => {
1075 | const eventHandler = vi.fn();
1076 | const client = new ReflagClient({
1077 | publishableKey: "test-key-cleanup",
1078 | user,
1079 | company,
1080 | other,
1081 | });
1082 |
1083 | // Mock the addHook method to return a cleanup function that we can spy on
1084 | const cleanupSpy = vi.fn();
1085 | const addHookSpy = vi
1086 | .spyOn(client["hooks"], "addHook")
1087 | .mockReturnValue(cleanupSpy);
1088 |
1089 | const { unmount } = renderHook(
1090 | () => useOnEvent("flagsUpdated", eventHandler),
1091 | {
1092 | wrapper: ({ children }) => (
1093 | <ReflagClientProvider client={client}>
1094 | {children}
1095 | </ReflagClientProvider>
1096 | ),
1097 | },
1098 | );
1099 |
1100 | // Verify that addHook was called with the correct parameters
1101 | expect(addHookSpy).toHaveBeenCalledWith("flagsUpdated", eventHandler);
1102 |
1103 | unmount();
1104 |
1105 | // Verify that the cleanup function was called
1106 | expect(cleanupSpy).toHaveBeenCalled();
1107 |
1108 | addHookSpy.mockRestore();
1109 | });
1110 |
1111 | test("throws error when used outside provider without client parameter", () => {
1112 | const consoleErrorSpy = vi
1113 | .spyOn(console, "error")
1114 | .mockImplementation(() => {
1115 | // Silence console.error during test
1116 | });
1117 | const eventHandler = vi.fn();
1118 |
1119 | expect(() => {
1120 | renderHook(() => useOnEvent("flagsUpdated", eventHandler));
1121 | }).toThrow(
1122 | "ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.",
1123 | );
1124 |
1125 | consoleErrorSpy.mockRestore();
1126 | });
1127 |
1128 | test("handles multiple event subscriptions", async () => {
1129 | const flagsHandler = vi.fn();
1130 | const stateHandler = vi.fn();
1131 | const client = new ReflagClient({
1132 | publishableKey: "test-key-multiple",
1133 | user,
1134 | company,
1135 | other,
1136 | });
1137 |
1138 | const { unmount } = renderHook(
1139 | () => {
1140 | useOnEvent("flagsUpdated", flagsHandler);
1141 | useOnEvent("stateUpdated", stateHandler);
1142 | },
1143 | {
1144 | wrapper: ({ children }) => (
1145 | <ReflagClientProvider client={client}>
1146 | {children}
1147 | </ReflagClientProvider>
1148 | ),
1149 | },
1150 | );
1151 |
1152 | // Initialize the client to trigger events
1153 | await client.initialize();
1154 |
1155 | // Wait for both events to be triggered
1156 | await waitFor(() => {
1157 | expect(flagsHandler).toHaveBeenCalled();
1158 | expect(stateHandler).toHaveBeenCalled();
1159 | });
1160 |
1161 | unmount();
1162 | });
1163 | });
1164 |
```