diff --git a/.changeset/solid-doodles-glow.md b/.changeset/solid-doodles-glow.md new file mode 100644 index 00000000..32f70c35 --- /dev/null +++ b/.changeset/solid-doodles-glow.md @@ -0,0 +1,5 @@ +--- +"@reflag/node-sdk": minor +--- + +improve flag override API for testing diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 80be0228..b49bdf03 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,11 +549,61 @@ describe("API Tests", () => { }); ``` -See more on flag overrides in the section below. +`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 remove: (() => void) | undefined; + + beforeEach(function () { + remove = reflagClient.pushFlagOverrides({ [name]: enabled }); + }); + + afterEach(function () { + remove?.(); + remove = undefined; + }); +}; + +describe("foo", () => { + describe("with new search ranking enabled", () => { + flag("search-ranking-v2", true); + + describe("with summaries enabled", () => { + flag("smart-summaries", true); + + // ... + }); + }); +}); +``` + +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 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(); +``` ## 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: +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 automated tests, see the [Testing](#testing) section above. + +When testing locally during development, you also have these additional ways to provide overrides: 1. Through environment variables: @@ -563,20 +631,6 @@ 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`: - -```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 }; - -// clear flag overrides. Same as setting to {}. -client.clearFlagOverrides(); -``` - 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/examples/express/app.test.ts b/packages/node-sdk/examples/express/app.test.ts index 69c46b22..0aff104d 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 1106175c..f9edd8b6 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 b9105c09..28e84f05 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 00000000..b5ef225c --- /dev/null +++ b/packages/node-sdk/examples/express/reflag.ts @@ -0,0 +1 @@ +export { default } from "./bucket"; diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 92583eae..c6e40560 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,13 @@ 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 +351,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()); @@ -377,45 +417,110 @@ 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, - * }; + * }); * ``` **/ + setFlagOverrides(overrides: FlagOverridesFn | FlagOverrides) { + this.baseFlagOverrides = normalizeFlagOverrides(overrides); + this.syncFlagOverrides(); + } + + /** + * @deprecated + * Use `setFlagOverrides()` for replacing the base override set. + **/ set flagOverrides(overrides: FlagOverridesFn | FlagOverrides) { - if (typeof overrides === "object") { - this._config.flagOverrides = () => overrides; - } else { - this._config.flagOverrides = overrides; - } + this.setFlagOverrides(overrides); } /** - * Clears the flag overrides. + * Temporarily layers flag overrides on top of the current overrides. + * + * @param overrides - The flag overrides to apply for the scoped period. + * + * @returns A remove function that removes only this override layer. * * @remarks - * This is useful for testing or development. + * 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 - * afterAll(() => { - * client.clearFlagOverrides(); + * let remove: (() => void) | undefined; + * + * beforeEach(() => { + * remove = client.pushFlagOverrides({ "flag-1": true }); + * }); + * + * afterEach(() => { + * remove?.(); + * remove = 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(); + }; + } + + /** + * Clears the base flag overrides. + * + * @remarks + * This does not affect temporary layers added with `pushFlagOverrides()`. + * + * @example + * ```ts + * client.clearFlagOverrides(); + * ``` + **/ clearFlagOverrides() { - this._config.flagOverrides = () => ({}); + this.baseFlagOverrides = () => ({}); + 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 29c083ce..7879bb77 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 fd1a6ede..83de6164 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({ @@ -1874,6 +1874,119 @@ 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 removeFlag1 = client.pushFlagOverrides({ + flag1: false, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + + const removeFlag2 = client.pushFlagOverrides({ + flag2: true, + }); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + removeFlag2(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(false); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + + removeFlag1(); + + 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.setFlagOverrides(() => ({ + flag1: false, + })); + + const remove = 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); + + remove(); + remove(); + + 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 remove = 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); + + remove(); + + 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 remove = client.pushFlagOverrides({ + flag2: true, + }); + + client.clearFlagOverrides(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(true); + + remove(); + + expect(client.getFlag(context, "flag1").isEnabled).toBe(true); + expect(client.getFlag(context, "flag2").isEnabled).toBe(false); + }); }); describe("getFlagsForBootstrap", () => {