From c921b04c03062057ac2d152ee3236841bfd38468 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 12 Mar 2026 20:20:17 +0100 Subject: [PATCH 1/8] Add scoped test flag override API --- packages/node-sdk/README.md | 29 +++++++ packages/node-sdk/src/client.ts | 120 +++++++++++++++++++++++--- packages/node-sdk/src/types.ts | 8 +- packages/node-sdk/test/client.test.ts | 62 +++++++++++++ 4 files changed, 203 insertions(+), 16 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 80be02286..a84f53fd4 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -577,6 +577,35 @@ client.flagOverrides = { myFlag: false }; client.clearFlagOverrides(); ``` +For nested or temporary test overrides, use `pushFlagOverrides()`. It layers on top of the current overrides and returns a restore function that removes only that layer. You can wrap that in a small helper: + +```typescript +export const flag = function (name: string, enabled: boolean): void { + let restore: (() => void) | undefined; + + beforeEach(function () { + restore = reflagClient.pushFlagOverrides({ [name]: enabled }); + }); + + afterEach(function () { + restore?.(); + restore = undefined; + }); +}; + +describe("foo", () => { + describe("with new search ranking enabled", () => { + flag("search-ranking-v2", true); + + describe("with summaries enabled", () => { + flag("smart-summaries", true); + + // ... + }); + }); +}); +``` + To get dynamic overrides, use a function which takes a context and returns a boolean or an object with the shape of `{isEnabled, config}`: ```typescript diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 92583eaed..15b74b9c9 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -63,6 +63,38 @@ import { const reflagConfigDefaultFile = "reflag.config.json"; type PartialBy = Omit & Partial>; +type FlagOverrideLayer = { + id: number; + overrides: FlagOverridesFn; +}; + +function normalizeFlagOverrides( + overrides: FlagOverridesFn | FlagOverrides | undefined, +): FlagOverridesFn { + if (typeof overrides === "function") { + return overrides; + } + + return () => overrides ?? {}; +} + +function composeFlagOverrides( + baseOverrides: FlagOverridesFn, + layers: FlagOverrideLayer[], +): FlagOverridesFn { + if (layers.length === 0) { + return baseOverrides; + } + + return (context) => + layers.reduce( + (acc, layer) => ({ + ...acc, + ...layer.overrides(context), + }), + baseOverrides(context), + ); +} type BulkEvent = | { @@ -138,6 +170,9 @@ export class ReflagClient { private flagsCache: Cache; private batchBuffer: BatchBuffer; private rateLimiter: ReturnType; + private baseFlagOverrides: FlagOverridesFn = () => ({}); + private flagOverrideLayers: FlagOverrideLayer[] = []; + private nextFlagOverrideLayerId = 0; /** * Gets the logger associated with the client. @@ -259,6 +294,12 @@ export class ReflagClient { ? options.logger : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel); + const baseFlagOverrides = normalizeFlagOverrides( + typeof config.flagOverrides === "function" || isObject(config.flagOverrides) + ? config.flagOverrides + : {}, + ); + const fallbackFlags = Array.isArray(options.fallbackFlags) ? options.fallbackFlags.reduce((acc, key) => { acc[key as TypedFlagKey] = { @@ -309,14 +350,12 @@ export class ReflagClient { refetchInterval: FLAGS_REFETCH_MS, staleWarningInterval: FLAGS_REFETCH_MS * 5, fallbackFlags: fallbackFlags, - flagOverrides: - typeof config.flagOverrides === "function" - ? config.flagOverrides - : () => config.flagOverrides, + flagOverrides: baseFlagOverrides, flagsFetchRetries: options.flagsFetchRetries ?? 3, fetchTimeoutMs: options.fetchTimeoutMs ?? API_TIMEOUT_MS, cacheStrategy: options.cacheStrategy ?? "periodically-update", }; + this.baseFlagOverrides = baseFlagOverrides; if ((config.batchOptions?.flushOnExit ?? true) && !this._config.offline) { triggerOnExit(() => this.flush()); @@ -394,11 +433,63 @@ export class ReflagClient { * ``` **/ set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { - if (typeof overrides === "object") { - this._config.flagOverrides = () => overrides; - } else { - this._config.flagOverrides = overrides; - } + this.baseFlagOverrides = normalizeFlagOverrides(overrides); + this.flagOverrideLayers = []; + this.syncFlagOverrides(); + } + + /** + * Temporarily layers flag overrides on top of the current overrides. + * + * @param overrides - The flag overrides to apply for the scoped period. + * + * @returns A restore function that removes only this override layer. + * + * @remarks + * This is intended for tests or other short-lived local overrides. The restore + * function is idempotent and can safely be called multiple times. + * + * @example + * ```ts + * let restore: (() => void) | undefined; + * + * beforeEach(() => { + * restore = client.pushFlagOverrides({ "flag-1": true }); + * }); + * + * afterEach(() => { + * restore?.(); + * restore = undefined; + * }); + * ``` + **/ + pushFlagOverrides(overrides: FlagOverridesFn | FlagOverrides): () => void { + const layer: FlagOverrideLayer = { + id: this.nextFlagOverrideLayerId++, + overrides: normalizeFlagOverrides(overrides), + }; + + this.flagOverrideLayers.push(layer); + this.syncFlagOverrides(); + + let restored = false; + + return () => { + if (restored) { + return; + } + restored = true; + + const layerIndex = this.flagOverrideLayers.findIndex( + ({ id }) => id === layer.id, + ); + if (layerIndex === -1) { + return; + } + + this.flagOverrideLayers.splice(layerIndex, 1); + this.syncFlagOverrides(); + }; } /** @@ -415,7 +506,16 @@ export class ReflagClient { * ``` **/ clearFlagOverrides() { - this._config.flagOverrides = () => ({}); + this.baseFlagOverrides = () => ({}); + this.flagOverrideLayers = []; + this.syncFlagOverrides(); + } + + private syncFlagOverrides() { + this._config.flagOverrides = composeFlagOverrides( + this.baseFlagOverrides, + this.flagOverrideLayers, + ); } /** diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 29c083cee..7879bb778 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -645,16 +645,12 @@ export type ClientOptions = { batchOptions?: Omit, "flushHandler" | "logger">; /** - * If a filename is specified, feature targeting results be overridden with - * the values from this file. The file should be a JSON object with flag - * keys as keys, and boolean or object as values. + * Local flag overrides for testing or development. * * If a function is specified, the function will be called with the context * and should return a record of flag keys and boolean or object values. - * - * Defaults to "reflagFlags.json". **/ - flagOverrides?: string | ((context: Context) => FlagOverrides); + flagOverrides?: FlagOverrides | ((context: Context) => FlagOverrides); /** * In offline mode, no data is sent or fetched from the the Reflag API. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index fd1a6ede1..82a4891ed 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1874,6 +1874,68 @@ describe("ReflagClient", () => { }, }); }); + + it("should push and restore layered flag overrides", async () => { + await client.initialize(); + const context = { user, company, other: otherContext }; + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + + const restoreFlag1 = client.pushFlagOverrides({ + flag1: false, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + + const restoreFlag2 = client.pushFlagOverrides({ + flag2: true, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + restoreFlag2(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + + restoreFlag1(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + }); + + it("should compose pushed flag overrides with existing override functions", async () => { + await client.initialize(); + const context = { user, company, other: otherContext }; + + client.flagOverrides = () => ({ + flag1: false, + }); + + const restore = client.pushFlagOverrides((overrideContext: Context) => { + expect(overrideContext).toStrictEqual({ + user, + company, + other: otherContext, + }); + + return { + flag2: true, + }; + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + restore(); + restore(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + }); }); describe("getFlagsForBootstrap", () => { From 0cc986f774aa8853418bcd4f2e315c9112de8758 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 12 Mar 2026 20:29:47 +0100 Subject: [PATCH 2/8] Clarify scoped and base flag override APIs --- packages/node-sdk/README.md | 17 +++++-- packages/node-sdk/src/client.ts | 30 ++++++------ packages/node-sdk/test/client.test.ts | 67 +++++++++++++++++++++++---- 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index a84f53fd4..e254b3810 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -565,19 +565,19 @@ REFLAG_FLAGS_DISABLED=flag3,flag4 1. Programmatically through the client options: -You can use a simple `Record` and pass it either in the constructor or by setting `client.flagOverrides`: +You can use a simple `Record` and pass it either in the constructor or by replacing the client's base overrides with `setFlagOverrides()`: ```typescript // pass directly in the constructor const client = new ReflagClient({ flagOverrides: { myFlag: true } }); -// or set on the client at a later time -client.flagOverrides = { myFlag: false }; +// or replace the base overrides at a later time +client.setFlagOverrides({ myFlag: false }); -// clear flag overrides. Same as setting to {}. +// clear only the base overrides client.clearFlagOverrides(); ``` -For nested or temporary test overrides, use `pushFlagOverrides()`. It layers on top of the current overrides and returns a restore function that removes only that layer. You can wrap that in a small helper: +`pushFlagOverrides()` serves a different purpose: it adds a temporary layer on top of the base overrides and returns a restore function that removes only that layer. This is useful for nested tests: ```typescript export const flag = function (name: string, enabled: boolean): void { @@ -606,6 +606,13 @@ describe("foo", () => { }); ``` +The precedence is: + +1. Base overrides from the constructor or `setFlagOverrides()` +2. Temporary layers added by `pushFlagOverrides()` + +If the same flag is set in both places, the pushed override wins until its restore function is called. + To get dynamic overrides, use a function which takes a context and returns a boolean or an object with the shape of `{isEnabled, config}`: ```typescript diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 15b74b9c9..73b169ec6 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -416,28 +416,35 @@ export class ReflagClient { } /** - * Sets the flag overrides. + * Replaces the base flag overrides used by the client. * * @param overrides - The flag overrides. * * @remarks - * The flag overrides are used to override the flag definitions. - * This is useful for testing or development. + * Base overrides are always applied before any temporary layers added through + * `pushFlagOverrides()`. * * @example * ```ts - * client.flagOverrides = { + * client.setFlagOverrides({ * "flag-1": true, * "flag-2": false, - * }; + * }); * ``` **/ - set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { + setFlagOverrides(overrides: FlagOverridesFn | FlagOverrides) { this.baseFlagOverrides = normalizeFlagOverrides(overrides); - this.flagOverrideLayers = []; this.syncFlagOverrides(); } + /** + * @deprecated + * Use `setFlagOverrides()` for replacing the base override set. + **/ + set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { + this.setFlagOverrides(overrides); + } + /** * Temporarily layers flag overrides on top of the current overrides. * @@ -493,21 +500,18 @@ export class ReflagClient { } /** - * Clears the flag overrides. + * Clears the base flag overrides. * * @remarks - * This is useful for testing or development. + * This does not affect temporary layers added with `pushFlagOverrides()`. * * @example * ```ts - * afterAll(() => { - * client.clearFlagOverrides(); - * }); + * client.clearFlagOverrides(); * ``` **/ clearFlagOverrides() { this.baseFlagOverrides = () => ({}); - this.flagOverrideLayers = []; this.syncFlagOverrides(); } diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 82a4891ed..1f885f775 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1748,7 +1748,7 @@ describe("ReflagClient", () => { ); }); - it("should use flag overrides", async () => { + it("should use base flag overrides", async () => { await client.initialize(); const context = { user, company, other: otherContext }; @@ -1773,9 +1773,9 @@ describe("ReflagClient", () => { }, }); - client.flagOverrides = { + client.setFlagOverrides({ flag1: false, - }; + }); const flags = client.getFlags(context); expect(flags).toStrictEqual({ @@ -1809,7 +1809,7 @@ describe("ReflagClient", () => { }); }); - it("should use flag overrides from function", async () => { + it("should use base flag overrides from function", async () => { await client.initialize(); const context = { user, company, other: otherContext }; @@ -1834,7 +1834,7 @@ describe("ReflagClient", () => { }, }); - client.flagOverrides = (_context: Context) => { + client.setFlagOverrides((_context: Context) => { expect(context).toStrictEqual(context); return { flag1: { isEnabled: false }, @@ -1847,7 +1847,7 @@ describe("ReflagClient", () => { }, }, }; - }; + }); const flags = client.getFlags(context); expect(flags).toStrictEqual({ @@ -1911,9 +1911,9 @@ describe("ReflagClient", () => { await client.initialize(); const context = { user, company, other: otherContext }; - client.flagOverrides = () => ({ + client.setFlagOverrides(() => ({ flag1: false, - }); + })); const restore = client.pushFlagOverrides((overrideContext: Context) => { expect(overrideContext).toStrictEqual({ @@ -1936,6 +1936,57 @@ describe("ReflagClient", () => { expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); }); + + it("should keep pushed overrides layered on top when replacing base overrides", async () => { + await client.initialize(); + const context = { user, company, other: otherContext }; + + client.setFlagOverrides({ + flag1: false, + }); + + const restore = client.pushFlagOverrides({ + flag1: true, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + + client.setFlagOverrides({ + flag1: false, + flag2: true, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + restore(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + }); + + it("should only clear the base overrides", async () => { + await client.initialize(); + const context = { user, company, other: otherContext }; + + client.setFlagOverrides({ + flag1: false, + }); + + const restore = client.pushFlagOverrides({ + flag2: true, + }); + + client.clearFlagOverrides(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + restore(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + }); }); describe("getFlagsForBootstrap", () => { From 8b257ca77e10f6a0f8e9302e32798ddea2b61f00 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 12 Mar 2026 20:46:00 +0100 Subject: [PATCH 3/8] Clarify pushed override removal semantics --- packages/node-sdk/README.md | 24 ++++++++++++++++++------ packages/node-sdk/src/client.ts | 12 ++++++------ packages/node-sdk/test/client.test.ts | 22 +++++++++++----------- 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index e254b3810..6c08859a7 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -577,19 +577,19 @@ client.setFlagOverrides({ myFlag: false }); client.clearFlagOverrides(); ``` -`pushFlagOverrides()` serves a different purpose: it adds a temporary layer on top of the base overrides and returns a restore function that removes only that layer. This is useful for nested tests: +`pushFlagOverrides()` serves a different purpose: it adds a temporary layer on top of the base overrides and returns a remove function that removes only that layer. This is useful for nested tests: ```typescript export const flag = function (name: string, enabled: boolean): void { - let restore: (() => void) | undefined; + let remove: (() => void) | undefined; beforeEach(function () { - restore = reflagClient.pushFlagOverrides({ [name]: enabled }); + remove = reflagClient.pushFlagOverrides({ [name]: enabled }); }); afterEach(function () { - restore?.(); - restore = undefined; + remove?.(); + remove = undefined; }); }; @@ -611,7 +611,19 @@ The precedence is: 1. Base overrides from the constructor or `setFlagOverrides()` 2. Temporary layers added by `pushFlagOverrides()` -If the same flag is set in both places, the pushed override wins until its restore function is called. +If the same flag is set in both places, the pushed override wins until its remove function is called. + +`pushFlagOverrides()` also accepts a function if the temporary override depends on the evaluation context: + +```typescript +const remove = client.pushFlagOverrides((context) => ({ + "smart-summaries": context.user?.id === "qa-user", +})); + +// ... + +remove(); +``` To get dynamic overrides, use a function which takes a context and returns a boolean or an object with the shape of `{isEnabled, config}`: diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 73b169ec6..73d7288fa 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -450,23 +450,23 @@ export class ReflagClient { * * @param overrides - The flag overrides to apply for the scoped period. * - * @returns A restore function that removes only this override layer. + * @returns A remove function that removes only this override layer. * * @remarks - * This is intended for tests or other short-lived local overrides. The restore + * This is intended for tests or other short-lived local overrides. The remove * function is idempotent and can safely be called multiple times. * * @example * ```ts - * let restore: (() => void) | undefined; + * let remove: (() => void) | undefined; * * beforeEach(() => { - * restore = client.pushFlagOverrides({ "flag-1": true }); + * remove = client.pushFlagOverrides({ "flag-1": true }); * }); * * afterEach(() => { - * restore?.(); - * restore = undefined; + * remove?.(); + * remove = undefined; * }); * ``` **/ diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 1f885f775..83de61640 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1882,26 +1882,26 @@ describe("ReflagClient", () => { expect(client.getFlag(context, "flag1").isEnabled).toBe(true); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - const restoreFlag1 = client.pushFlagOverrides({ + const removeFlag1 = client.pushFlagOverrides({ flag1: false, }); expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - const restoreFlag2 = client.pushFlagOverrides({ + const removeFlag2 = client.pushFlagOverrides({ flag2: true, }); expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - restoreFlag2(); + removeFlag2(); expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); - restoreFlag1(); + removeFlag1(); expect(client.getFlag(context, "flag1").isEnabled).toBe(true); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); @@ -1915,7 +1915,7 @@ describe("ReflagClient", () => { flag1: false, })); - const restore = client.pushFlagOverrides((overrideContext: Context) => { + const remove = client.pushFlagOverrides((overrideContext: Context) => { expect(overrideContext).toStrictEqual({ user, company, @@ -1930,8 +1930,8 @@ describe("ReflagClient", () => { expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - restore(); - restore(); + remove(); + remove(); expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); @@ -1945,7 +1945,7 @@ describe("ReflagClient", () => { flag1: false, }); - const restore = client.pushFlagOverrides({ + const remove = client.pushFlagOverrides({ flag1: true, }); @@ -1959,7 +1959,7 @@ describe("ReflagClient", () => { expect(client.getFlag(context, "flag1").isEnabled).toBe(true); expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - restore(); + remove(); expect(client.getFlag(context, "flag1").isEnabled).toBe(false); expect(client.getFlag(context, "flag2").isEnabled).toBe(true); @@ -1973,7 +1973,7 @@ describe("ReflagClient", () => { flag1: false, }); - const restore = client.pushFlagOverrides({ + const remove = client.pushFlagOverrides({ flag2: true, }); @@ -1982,7 +1982,7 @@ describe("ReflagClient", () => { expect(client.getFlag(context, "flag1").isEnabled).toBe(true); expect(client.getFlag(context, "flag2").isEnabled).toBe(true); - restore(); + remove(); expect(client.getFlag(context, "flag1").isEnabled).toBe(true); expect(client.getFlag(context, "flag2").isEnabled).toBe(false); From 155c89edde4f5d4d099042aa4ede63feac603943 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 12 Mar 2026 20:53:23 +0100 Subject: [PATCH 4/8] Format node-sdk override client changes --- packages/node-sdk/src/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 73d7288fa..c6e40560e 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -295,7 +295,8 @@ export class ReflagClient { : applyLogLevel(decorateLogger(REFLAG_LOG_PREFIX, console), logLevel); const baseFlagOverrides = normalizeFlagOverrides( - typeof config.flagOverrides === "function" || isObject(config.flagOverrides) + typeof config.flagOverrides === "function" || + isObject(config.flagOverrides) ? config.flagOverrides : {}, ); From 9295b761f88cfc875d71a227365113d0891316ff Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 13 Mar 2026 12:02:42 +0100 Subject: [PATCH 5/8] changeset summary --- .changeset/solid-doodles-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/solid-doodles-glow.md diff --git a/.changeset/solid-doodles-glow.md b/.changeset/solid-doodles-glow.md new file mode 100644 index 000000000..32f70c35e --- /dev/null +++ b/.changeset/solid-doodles-glow.md @@ -0,0 +1,5 @@ +--- +"@reflag/node-sdk": minor +--- + +improve flag override API for testing From ac7031a443bb0e55b766eb589ffc3eeb56b9c72a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 13 Mar 2026 12:09:53 +0100 Subject: [PATCH 6/8] docs(node-sdk): move programmatic overrides into testing --- packages/node-sdk/README.md | 106 +++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 6c08859a7..4fe85b23f 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -498,14 +498,32 @@ reflagClient.initialize().then(() => { ## Testing -When writing tests that cover code with flags, you can toggle flags on/off programmatically to test the different behavior. +When writing tests that cover code with flags, you can toggle flags on/off programmatically to test different behavior. For tests, you will often want to run the client in offline mode and provide flag overrides directly through the client options. `reflag.ts`: ```typescript import { ReflagClient } from "@reflag/node-sdk"; -export const reflag = new ReflagClient(); +export const reflag = new ReflagClient({ + offline: true, +}); +``` + +You can then set base overrides for a test run by passing `flagOverrides` in the constructor, replacing them later with `setFlagOverrides()`, or clearing them with `clearFlagOverrides()`: + +```typescript +// pass directly in the constructor +const client = new ReflagClient({ + offline: true, + flagOverrides: { myFlag: true }, +}); + +// or replace the base overrides at a later time +client.setFlagOverrides({ myFlag: false }); + +// clear only the base overrides +client.clearFlagOverrides(); ``` `app.test.ts`: @@ -520,9 +538,9 @@ afterEach(() => { describe("API Tests", () => { it("should return 200 for the root endpoint", async () => { - reflag.flagOverrides = { + reflag.setFlagOverrides({ "show-todo": true, - }; + }); const response = await request(app).get("/"); expect(response.status).toBe(200); @@ -531,52 +549,6 @@ describe("API Tests", () => { }); ``` -See more on flag overrides in the section below. - -## Flag Overrides - -Flag overrides allow you to override flags and their configurations locally. This is particularly useful for development and testing. You can specify overrides in three ways: - -1. Through environment variables: - -```bash -REFLAG_FLAGS_ENABLED=flag1,flag2 -REFLAG_FLAGS_DISABLED=flag3,flag4 -``` - -1. Through `reflag.config.json`: - -```json -{ - "flagOverrides": { - "delete-todos": { - "isEnabled": true, - "config": { - "key": "dev-config", - "payload": { - "requireConfirmation": true, - "maxDeletionsPerDay": 5 - } - } - } - } -} -``` - -1. Programmatically through the client options: - -You can use a simple `Record` and pass it either in the constructor or by replacing the client's base overrides with `setFlagOverrides()`: - -```typescript -// pass directly in the constructor -const client = new ReflagClient({ flagOverrides: { myFlag: true } }); -// or replace the base overrides at a later time -client.setFlagOverrides({ myFlag: false }); - -// clear only the base overrides -client.clearFlagOverrides(); -``` - `pushFlagOverrides()` serves a different purpose: it adds a temporary layer on top of the base overrides and returns a remove function that removes only that layer. This is useful for nested tests: ```typescript @@ -625,6 +597,40 @@ const remove = client.pushFlagOverrides((context) => ({ remove(); ``` +## Flag Overrides + +Flag overrides allow you to override flags and their configurations locally. This is particularly useful for development and testing. + +For tests, the recommended setup is to run the client in offline mode and configure overrides programmatically through the client options as shown above. + +When running locally, you also have these additional ways to provide overrides: + +1. Through environment variables: + +```bash +REFLAG_FLAGS_ENABLED=flag1,flag2 +REFLAG_FLAGS_DISABLED=flag3,flag4 +``` + +1. Through `reflag.config.json`: + +```json +{ + "flagOverrides": { + "delete-todos": { + "isEnabled": true, + "config": { + "key": "dev-config", + "payload": { + "requireConfirmation": true, + "maxDeletionsPerDay": 5 + } + } + } + } +} +``` + To get dynamic overrides, use a function which takes a context and returns a boolean or an object with the shape of `{isEnabled, config}`: ```typescript From cc7b8adade9ff4d09a1a8b30c70c6d091004c775 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 13 Mar 2026 12:53:47 +0100 Subject: [PATCH 7/8] docs(node-sdk): clarify local flag override usage --- packages/node-sdk/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 4fe85b23f..b49bdf031 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -599,11 +599,11 @@ remove(); ## Flag Overrides -Flag overrides allow you to override flags and their configurations locally. This is particularly useful for development and testing. +Flag overrides allow you to override flags and their configurations locally. This is particularly useful when testing changes locally, for example when running your app and clicking around to verify behavior before deploying your changes. -For tests, the recommended setup is to run the client in offline mode and configure overrides programmatically through the client options as shown above. +For automated tests, see the [Testing](#testing) section above. -When running locally, you also have these additional ways to provide overrides: +When testing locally during development, you also have these additional ways to provide overrides: 1. Through environment variables: From 08d82f71e33687f845f0c2e8746fbe8e79279fb5 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 13 Mar 2026 13:21:06 +0100 Subject: [PATCH 8/8] Update node-sdk example tests for flag overrides API --- .../node-sdk/examples/express/app.test.ts | 37 ++++++++++++++----- packages/node-sdk/examples/express/app.ts | 2 +- packages/node-sdk/examples/express/bucket.ts | 8 ++-- packages/node-sdk/examples/express/reflag.ts | 1 + 4 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 packages/node-sdk/examples/express/reflag.ts diff --git a/packages/node-sdk/examples/express/app.test.ts b/packages/node-sdk/examples/express/app.test.ts index 69c46b224..0aff104d9 100644 --- a/packages/node-sdk/examples/express/app.test.ts +++ b/packages/node-sdk/examples/express/app.test.ts @@ -1,14 +1,32 @@ import request from "supertest"; import app, { todos } from "./app"; -import { beforeEach, describe, it, expect, beforeAll } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import reflag from "./reflag"; +function flag(name: string, enabled: boolean): void { + let remove: (() => void) | undefined; + + beforeEach(() => { + remove = reflag.pushFlagOverrides({ [name]: enabled }); + }); + + afterEach(() => { + remove?.(); + remove = undefined; + }); +} + beforeAll(async () => await reflag.initialize()); + beforeEach(() => { - reflag.featureOverrides = { + reflag.setFlagOverrides({ "show-todos": true, - }; + }); +}); + +afterEach(() => { + reflag.clearFlagOverrides(); }); describe("API Tests", () => { @@ -24,12 +42,13 @@ describe("API Tests", () => { expect(response.body).toEqual({ todos }); }); - it("should return no todos when list is disabled", async () => { - reflag.featureOverrides = () => ({ - "show-todos": false, + describe("with show-todos temporarily disabled", () => { + flag("show-todos", false); + + it("should return no todos", async () => { + const response = await request(app).get("/todos"); + expect(response.status).toBe(200); + expect(response.body).toEqual({ todos: [] }); }); - const response = await request(app).get("/todos"); - expect(response.status).toBe(200); - expect(response.body).toEqual({ todos: [] }); }); }); diff --git a/packages/node-sdk/examples/express/app.ts b/packages/node-sdk/examples/express/app.ts index 1106175c4..f9edd8b62 100644 --- a/packages/node-sdk/examples/express/app.ts +++ b/packages/node-sdk/examples/express/app.ts @@ -1,6 +1,6 @@ import reflag from "./reflag"; import express from "express"; -import { BoundReflagClient } from "../src"; +import type { BoundReflagClient } from "../../src"; // Augment the Express types to include the `reflagUser` property on the `res.locals` object // This will allow us to access the ReflagClient instance in our route handlers diff --git a/packages/node-sdk/examples/express/bucket.ts b/packages/node-sdk/examples/express/bucket.ts index b9105c09c..28e84f053 100644 --- a/packages/node-sdk/examples/express/bucket.ts +++ b/packages/node-sdk/examples/express/bucket.ts @@ -1,11 +1,11 @@ -import { ReflagClient, Context, FlagOverrides } from "../../"; +import { ReflagClient, Context, FlagOverrides } from "../../src"; type CreateConfigPayload = { minimumLength: number; }; // Extending the Flags interface to define the available features -declare module "../../types" { +declare module "../../src/types" { interface Flags { "show-todos": boolean; "create-todos": { @@ -18,7 +18,7 @@ declare module "../../types" { } } -let featureOverrides = (_: Context): FlagOverrides => { +const flagOverrides = (_: Context): FlagOverrides => { return { "create-todos": { isEnabled: true, @@ -39,5 +39,5 @@ let featureOverrides = (_: Context): FlagOverrides => { export default new ReflagClient({ // Optional: Set a logger to log debug information, errors, etc. logger: console, - featureOverrides, // Optional: Set feature overrides + flagOverrides, // Optional: Set flag overrides }); diff --git a/packages/node-sdk/examples/express/reflag.ts b/packages/node-sdk/examples/express/reflag.ts new file mode 100644 index 000000000..b5ef225c0 --- /dev/null +++ b/packages/node-sdk/examples/express/reflag.ts @@ -0,0 +1 @@ +export { default } from "./bucket";