diff --git a/lib/addons/abTestAssignment.md b/lib/addons/abTestAssignment.md new file mode 100644 index 00000000..d79adbd9 --- /dev/null +++ b/lib/addons/abTestAssignment.md @@ -0,0 +1,118 @@ +# A/B Test Assignment Addon + +This addon manages A/B test assignment for Optable SDK bundles. It handles variant assignment with traffic weighting, sticky assignment via `localStorage`, and a `sessionStorage` override for testing. It also stamps the assigned variant onto Prebid.js bid requests so it is picked up by the Optable Prebid Analytics addon. + +## Usage + +### Basic setup + +```js +import { setupAB } from "@optable/web-sdk/lib/addons/abTestAssignment"; + +const ab = setupAB({ + variants: [ + { id: "all" }, // treatment — gets remaining traffic (95%) + { id: "none", trafficPercentage: 5 }, // control — 5% + ], +}); + +if (ab.isControl) { + // Optable is disabled for this user + window.optable.disabled = true; +} +``` + +### With Prebid.js analytics + +Pass `pbjs` to `setupAB` and hooks are registered automatically. Pass it **before** calling `analytics.hookIntoPrebid()` so bids are stamped before the analytics addon reads them. + +```js +import { setupAB } from "@optable/web-sdk/lib/addons/abTestAssignment"; +import OptablePrebidAnalytics from "@optable/web-sdk/lib/addons/prebid/analytics"; + +const ab = setupAB({ + variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], + pbjs, // hooks registered automatically +}); + +const analytics = new OptablePrebidAnalytics(sdkInstance, { analytics: true }); +analytics.hookIntoPrebid(); +``` + +If `pbjs` is not yet available at setup time, call `ab.setHooks(pbjs)` manually once it is: + +```js +const ab = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); +window.pbjs.que.push(() => ab.setHooks(window.pbjs)); +``` + +### Custom variant names + +```js +const ab = setupAB({ + variants: [ + { id: "treatment", trafficPercentage: 90 }, + { id: "holdout", trafficPercentage: 10 }, + ], + controlId: "holdout", + treatmentId: "treatment", +}); +``` + +### More than two variants + +Omit `trafficPercentage` on any variants you want to share the remaining traffic equally. + +```js +const ab = setupAB({ + variants: [ + { id: "control", trafficPercentage: 10 }, + { id: "variant-a" }, // 45% + { id: "variant-b" }, // 45% + ], + controlId: "control", + treatmentId: "variant-a", +}); +``` + +## API + +### `setupAB(config)` + +**Config options** + +| Option | Type | Default | Description | +| ------------- | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `variants` | `ABTestVariant[]` | required | List of variants. Each has an `id` and an optional `trafficPercentage`. Variants without `trafficPercentage` share the remaining traffic equally. | +| `storageKey` | `string` | `"OPTABLE_SPLIT_TEST"` | `localStorage` key used to persist the assignment across sessions. | +| `controlId` | `string` | `"none"` | The variant `id` considered the control group. Used to resolve `isControl` and the `optableControlGroup` flag override. | +| `treatmentId` | `string` | `"all"` | The variant `id` considered the treatment group. Used to resolve `isControl` and the `optableControlGroup` flag override. | +| `sdk` | `OptableSDK` | — | When provided, uses `sdk.targetingClearCache()` for precise control-group cache clearing instead of a key-prefix scan. | +| `pbjs` | `object` | — | When provided, bid-stamping hooks are registered on `pbjs` automatically at setup time. | + +**Returned object** + +| Property | Type | Description | +| --------------------- | ---------------- | -------------------------------------------------------------------------------------------------- | +| `variant` | `ABTestConfig` | The assigned variant (`id` + `trafficPercentage`). | +| `isControl` | `boolean` | `true` when the assigned variant is not the treatment. | +| `splitTestAssignment` | `string` | The assigned variant id. | +| `setHooks` | `(pbjs) => void` | Registers bid-stamping hooks on a Prebid instance. Use when `pbjs` is not available at setup time. | + +## Overriding the assignment for testing + +Add `optableControlGroup` to the page URL: + +``` +https://example.com/page?optableControlGroup=1 # force control +https://example.com/page?optableControlGroup=0 # force treatment +``` + +Or set it in `sessionStorage` before the SDK initializes: + +```js +sessionStorage.setItem("optableControlGroup", "1"); // force control +sessionStorage.setItem("optableControlGroup", "0"); // force treatment +``` + +URL params take precedence over `sessionStorage`. Clear `localStorage.OPTABLE_SPLIT_TEST` to reset a sticky assignment. diff --git a/lib/addons/abTestAssignment.test.ts b/lib/addons/abTestAssignment.test.ts new file mode 100644 index 00000000..bd272db8 --- /dev/null +++ b/lib/addons/abTestAssignment.test.ts @@ -0,0 +1,287 @@ +import { setupAB } from "./abTestAssignment.ts"; +import { determineABTest } from "../edge/abTest"; +import { resetFlags } from "../core/flags"; + +const STORAGE_KEY = "OPTABLE_SPLIT_TEST"; + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + resetFlags(); + jest.spyOn(Math, "random").mockReturnValue(0.5); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("setupAB - traffic assignment", () => { + it("assigns the treatment variant when random bucket falls in its range", () => { + jest.spyOn(Math, "random").mockReturnValue(0.0); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("all"); + expect(result.isControl).toBe(false); + }); + + it("assigns the control variant when random bucket falls in its range", () => { + jest.spyOn(Math, "random").mockReturnValue(0.97); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("none"); + expect(result.isControl).toBe(true); + }); + + it("distributes remaining traffic equally among variants without trafficPercentage", () => { + jest.spyOn(Math, "random").mockReturnValue(0.6); + const result = setupAB({ + variants: [{ id: "a", trafficPercentage: 50 }, { id: "b" }, { id: "c" }], + }); + expect(["b", "c"]).toContain(result.variant.id); + }); +}); + +describe("setupAB - localStorage stickiness", () => { + it("persists the assignment to localStorage", () => { + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(stored.id).toBe("all"); + }); + + it("returns the cached assignment on subsequent calls", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: "none", trafficPercentage: 5 })); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("none"); + }); + + it("ignores a cached value whose id is not in the variants list", () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: "stale", trafficPercentage: 50 })); + jest.spyOn(Math, "random").mockReturnValue(0.0); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("all"); + }); + + it("respects a custom storageKey", () => { + const key = "MY_AB_TEST"; + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], storageKey: key }); + expect(localStorage.getItem(key)).not.toBeNull(); + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); +}); + +describe("setupAB - override via flags", () => { + it("forces control when optableControlGroup flag is '1' (sessionStorage)", () => { + sessionStorage.setItem("optableControlGroup", "1"); + resetFlags(); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("none"); + expect(result.isControl).toBe(true); + }); + + it("forces treatment when optableControlGroup flag is '0' (sessionStorage)", () => { + sessionStorage.setItem("optableControlGroup", "0"); + resetFlags(); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("all"); + expect(result.isControl).toBe(false); + }); +}); + +describe("setupAB - custom variant ids", () => { + it("uses controlId and treatmentId to resolve isControl and flag overrides", () => { + sessionStorage.setItem("optableControlGroup", "0"); + resetFlags(); + const result = setupAB({ + variants: [{ id: "treatment" }, { id: "control", trafficPercentage: 10 }], + controlId: "control", + treatmentId: "treatment", + }); + expect(result.variant.id).toBe("treatment"); + expect(result.isControl).toBe(false); + }); +}); + +describe("setupAB - control group cache clearing", () => { + it("clears OPTABLE_RESOLVED and OPTABLE_TARGETING_* when assigned to control", () => { + localStorage.setItem("OPTABLE_RESOLVED", "stale"); + localStorage.setItem("OPTABLE_TARGETING_abc123", "stale"); + localStorage.setItem("OPTABLE_TARGETING_def456", "stale"); + jest.spyOn(Math, "random").mockReturnValue(0.97); // control bucket + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(localStorage.getItem("OPTABLE_RESOLVED")).toBeNull(); + expect(localStorage.getItem("OPTABLE_TARGETING_abc123")).toBeNull(); + expect(localStorage.getItem("OPTABLE_TARGETING_def456")).toBeNull(); + }); + + it("does not clear targeting cache when assigned to treatment", () => { + localStorage.setItem("OPTABLE_RESOLVED", "valid"); + localStorage.setItem("OPTABLE_TARGETING_abc123", "valid"); + jest.spyOn(Math, "random").mockReturnValue(0.0); // treatment bucket + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(localStorage.getItem("OPTABLE_RESOLVED")).toBe("valid"); + expect(localStorage.getItem("OPTABLE_TARGETING_abc123")).toBe("valid"); + }); + + it("calls sdk.targetingClearCache() instead of prefix scan when sdk is provided", () => { + const mockSdk = { targetingClearCache: jest.fn() }; + jest.spyOn(Math, "random").mockReturnValue(0.97); // control bucket + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], sdk: mockSdk }); + expect(mockSdk.targetingClearCache).toHaveBeenCalledTimes(1); + }); + + it("clears targeting cache when control is forced via flag override", () => { + localStorage.setItem("OPTABLE_RESOLVED", "stale"); + sessionStorage.setItem("optableControlGroup", "1"); + resetFlags(); + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(localStorage.getItem("OPTABLE_RESOLVED")).toBeNull(); + }); +}); + +describe("setupAB - splitTestAssignment", () => { + it("exposes the assigned variant id as a string", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.splitTestAssignment).toBe(result.variant.id); + }); +}); + +describe("setupAB - setHooks", () => { + it("registers an onEvent handler for auctionEnd", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const pbjs = { getEvents: () => [], onEvent: jest.fn() }; + result.setHooks(pbjs); + expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); + + it("replays past auctionEnd events from getEvents", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const bid: any = { bidId: "bid-1" }; + const pbjs = { + getEvents: () => [{ eventType: "auctionEnd", args: { bidderRequests: [{ bids: [bid] }] } }], + onEvent: jest.fn(), + }; + result.setHooks(pbjs); + expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("all"); + }); + + it("stamps bids and does not overwrite an existing splitTestAssignment", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { splitTestAssignment: "control" } } } }; + const pbjs = { + getEvents: () => [{ eventType: "auctionEnd", args: { bidderRequests: [{ bids: [bid] }] } }], + onEvent: jest.fn(), + }; + result.setHooks(pbjs); + expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("control"); + }); + + it("registers hooks automatically when pbjs is passed to setupAB", () => { + const pbjs = { getEvents: () => [], onEvent: jest.fn() }; + setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], pbjs }); + expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function)); + }); +}); + +describe("determineABTest", () => { + it("returns null when no abTests provided", () => { + expect(determineABTest()).toBeNull(); + expect(determineABTest(undefined)).toBeNull(); + expect(determineABTest([])).toBeNull(); + }); + + it("returns null when traffic percentage sum exceeds 100%", () => { + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + expect( + determineABTest([ + { id: "a", trafficPercentage: 60 }, + { id: "b", trafficPercentage: 50 }, + ]) + ).toBeNull(); + expect(consoleSpy).toHaveBeenCalledWith("AB Test Config Error: Traffic Percentage Sum Exceeds 100%"); + consoleSpy.mockRestore(); + }); + + it("returns correct test based on random bucket", () => { + const abTests = [ + { id: "test1", trafficPercentage: 30 }, + { id: "test2", trafficPercentage: 40 }, + { id: "test3", trafficPercentage: 20 }, + ]; + jest.spyOn(Math, "random").mockReturnValue(0.15); + expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 30 }); + jest.spyOn(Math, "random").mockReturnValue(0.5); + expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 40 }); + jest.spyOn(Math, "random").mockReturnValue(0.8); + expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 20 }); + jest.spyOn(Math, "random").mockReturnValue(0.95); + expect(determineABTest(abTests)).toBeNull(); + }); + + it("handles single test configuration", () => { + const abTests = [{ id: "single-test", trafficPercentage: 50 }]; + jest.spyOn(Math, "random").mockReturnValue(0.25); + expect(determineABTest(abTests)).toEqual({ id: "single-test", trafficPercentage: 50 }); + jest.spyOn(Math, "random").mockReturnValue(0.75); + expect(determineABTest(abTests)).toBeNull(); + }); + + it("handles edge cases with 0% traffic", () => { + jest.spyOn(Math, "random").mockReturnValue(0.5); + expect( + determineABTest([ + { id: "a", trafficPercentage: 0 }, + { id: "b", trafficPercentage: 100 }, + ]) + ).toEqual({ + id: "b", + trafficPercentage: 100, + }); + }); + + it("handles tests with matcher_override and skipMatchers", () => { + jest.spyOn(Math, "random").mockReturnValue(0.25); + const abTests = [ + { id: "test1", trafficPercentage: 50, matcher_override: [{ id: "override1", rank: 1 }], skipMatchers: ["1p"] }, + { id: "test2", trafficPercentage: 50, matcher_override: [{ id: "override2", rank: 2 }], skipMatchers: ["1p"] }, + ]; + expect(determineABTest(abTests)).toEqual({ + id: "test1", + trafficPercentage: 50, + matcher_override: [{ id: "override1", rank: 1 }], + skipMatchers: ["1p"], + }); + }); + + it("handles tests with skipResolvers", () => { + jest.spyOn(Math, "random").mockReturnValue(0.25); + expect( + determineABTest([ + { id: "test1", trafficPercentage: 50, skipResolvers: ["resolver1"] }, + { id: "test2", trafficPercentage: 50, skipResolvers: ["resolver2"] }, + ]) + ).toEqual({ id: "test1", trafficPercentage: 50, skipResolvers: ["resolver1"] }); + }); + + it("handles boundary conditions", () => { + const abTests = [ + { id: "test1", trafficPercentage: 50 }, + { id: "test2", trafficPercentage: 50 }, + ]; + jest.spyOn(Math, "random").mockReturnValue(0.5); + expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 50 }); + jest.spyOn(Math, "random").mockReturnValue(0.499); + expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 50 }); + }); + + it("handles floating point precision", () => { + const abTests = [ + { id: "test1", trafficPercentage: 33.33 }, + { id: "test2", trafficPercentage: 33.33 }, + { id: "test3", trafficPercentage: 33.34 }, + ]; + jest.spyOn(Math, "random").mockReturnValue(0.333); + expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 33.33 }); + jest.spyOn(Math, "random").mockReturnValue(0.666); + expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 33.33 }); + jest.spyOn(Math, "random").mockReturnValue(0.999); + expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 33.34 }); + }); +}); diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts new file mode 100644 index 00000000..ccbdd8e0 --- /dev/null +++ b/lib/addons/abTestAssignment.ts @@ -0,0 +1,149 @@ +import type { ABTestConfig } from "../config"; +import { determineABTest } from "../edge/abTest"; +import { getFlags } from "../core/flags"; + +const DEFAULT_STORAGE_KEY = "OPTABLE_SPLIT_TEST"; + +export interface ABTestVariant { + id: string; + trafficPercentage?: number; +} + +export interface SetupABConfig { + variants: ABTestVariant[]; + storageKey?: string; + // The variant id treated as "control" (Optable disabled). Defaults to 'none'. + controlId?: string; + // The variant id treated as "treatment" (Optable enabled). Defaults to 'all'. + treatmentId?: string; + // An initialized SDK instance. When provided, targetingClearCache() is used + // for precise cache clearing in the control group instead of a prefix scan. + sdk?: { targetingClearCache: () => void }; + // A Prebid.js instance. When provided, bid stamping hooks are registered automatically. + pbjs?: any; +} + +export interface ABTestSetupResult { + variant: ABTestConfig; + isControl: boolean; + splitTestAssignment: string; + // For deferred hook registration when pbjs is not yet available at setup time. + setHooks: (pbjs: any) => void; +} + +function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] { + const allocated = variants.reduce((sum, v) => sum + (v.trafficPercentage ?? 0), 0); + const unassigned = variants.filter((v) => v.trafficPercentage === undefined); + const each = unassigned.length > 0 ? (100 - allocated) / unassigned.length : 0; + return variants.map((v) => ({ + id: v.id, + trafficPercentage: v.trafficPercentage ?? each, + })); +} + +export function setupAB(config: SetupABConfig): ABTestSetupResult { + const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all", sdk, pbjs } = config; + + // Process the provided variant config so that every variant has an explicit traffic percentage. + // Variants without one share the remaining percentage equally. + const filled = fillTrafficPercentages(variants); + + let selected: ABTestConfig | null = null; + + // Priority 1 — QA/debug override via URL param or sessionStorage flag. + // ?optableControlGroup=1 forces the control variant; =0 forces treatment. + // This lets QA verify both branches without clearing localStorage. + const controlGroupFlag = getFlags().optableControlGroup; + if (controlGroupFlag === "1") { + selected = filled.find((v) => v.id === controlId) ?? { id: controlId, trafficPercentage: 0 }; + } else if (controlGroupFlag === "0") { + selected = filled.find((v) => v.id === treatmentId) ?? { id: treatmentId, trafficPercentage: 0 }; + } + + // Priority 2 — sticky assignment from a previous visit. + // Once a user is assigned a variant it must not change across page loads or + // sessions, otherwise the same user could appear in both groups. We validate + // the cached id against the current variant list so a stale cache from an + // old experiment config is silently discarded. + if (!selected) { + try { + const cached = localStorage.getItem(storageKey); + if (cached) { + const parsed = JSON.parse(cached); + if (parsed?.id && filled.some((v) => v.id === parsed.id)) { + selected = parsed as ABTestConfig; + } + } + } catch { + // localStorage unavailable or invalid JSON + } + } + + // Priority 3 — first visit: randomly assign based on traffic weights. + // determineABTest returns null when the random bucket falls outside all + // defined ranges (i.e. weights sum to less than 100). filled[0] is the + // fallback so selected is always non-null after this point. + if (!selected) { + selected = determineABTest(filled) ?? filled[0]; + } + + // Persist the assignment so subsequent visits return the same variant. + try { + localStorage.setItem(storageKey, JSON.stringify(selected)); + } catch { + // localStorage unavailable + } + + const isControl = selected.id !== treatmentId; + const assignment = selected.id; + + // Control group: clear cached targeting data so RTD, PPID and TargetingFromCache + // serve nothing for this user. Without this, a user moved into the control group + // would still receive Optable targeting from a previous session's cache. + if (isControl) { + try { + localStorage.removeItem("OPTABLE_RESOLVED"); + if (sdk) { + sdk.targetingClearCache(); + } else { + Object.keys(localStorage) + .filter((k) => k.startsWith("OPTABLE_TARGETING_")) + .forEach((k) => localStorage.removeItem(k)); + } + } catch { + // localStorage unavailable + } + } + + function applyToAuctionEvent(event: { bidderRequests?: any[] }): void { + (event.bidderRequests || []).forEach((br: any) => { + (br.bids || []).forEach((b: any) => { + if (b.ortb2Imp?.ext?.optable?.splitTestAssignment) return; + b.ortb2Imp = b.ortb2Imp || {}; + b.ortb2Imp.ext = b.ortb2Imp.ext || {}; + b.ortb2Imp.ext.optable = b.ortb2Imp.ext.optable || {}; + b.ortb2Imp.ext.optable.splitTestAssignment = assignment; + }); + }); + } + + function setHooks(pbjsInstance: any): void { + pbjsInstance.getEvents().forEach((event: any) => { + if (event.eventType === "auctionEnd") { + applyToAuctionEvent(event.args); + } + }); + pbjsInstance.onEvent("auctionEnd", applyToAuctionEvent); + } + + if (pbjs) { + setHooks(pbjs); + } + + return { + variant: selected, + isControl, + splitTestAssignment: assignment, + setHooks, + }; +} diff --git a/lib/core/flags.test.ts b/lib/core/flags.test.ts new file mode 100644 index 00000000..2994d462 --- /dev/null +++ b/lib/core/flags.test.ts @@ -0,0 +1,74 @@ +import { getFlags, resetFlags } from "./flags"; + +beforeEach(() => { + sessionStorage.clear(); + resetFlags(); + Object.defineProperty(window, "location", { + value: { search: "" }, + writable: true, + configurable: true, + }); +}); + +describe("getFlags - URL params", () => { + it("reads a flag from the URL query string", () => { + window.location = { search: "?optableDebug=1" } as Location; + resetFlags(); + expect(getFlags().optableDebug).toBe("1"); + }); + + it("uses '1' when a flag is present in the URL with no value", () => { + window.location = { search: "?optableDebug" } as Location; + resetFlags(); + expect(getFlags().optableDebug).toBe("1"); + }); + + it("reads optableControlGroup from the URL", () => { + window.location = { search: "?optableControlGroup=1" } as Location; + resetFlags(); + expect(getFlags().optableControlGroup).toBe("1"); + }); + + it("reads multiple flags from the URL", () => { + window.location = { search: "?optableDebug=1&optableForceTargeting=1" } as Location; + resetFlags(); + const flags = getFlags(); + expect(flags.optableDebug).toBe("1"); + expect(flags.optableForceTargeting).toBe("1"); + }); + + it("ignores unknown URL params", () => { + window.location = { search: "?somethingElse=1" } as Location; + resetFlags(); + expect(getFlags()).toEqual({}); + }); +}); + +describe("getFlags - sessionStorage fallback", () => { + it("reads a flag from sessionStorage when not in the URL", () => { + sessionStorage.setItem("optableControlGroup", "1"); + expect(getFlags().optableControlGroup).toBe("1"); + }); + + it("URL takes precedence over sessionStorage", () => { + sessionStorage.setItem("optableDebug", "0"); + window.location = { search: "?optableDebug=1" } as Location; + resetFlags(); + expect(getFlags().optableDebug).toBe("1"); + }); +}); + +describe("getFlags - singleton", () => { + it("returns the same object on repeated calls", () => { + expect(getFlags()).toBe(getFlags()); + }); + + it("re-parses after resetFlags", () => { + const first = getFlags(); + resetFlags(); + sessionStorage.setItem("optableDebug", "1"); + const second = getFlags(); + expect(first).not.toBe(second); + expect(second.optableDebug).toBe("1"); + }); +}); diff --git a/lib/core/flags.ts b/lib/core/flags.ts new file mode 100644 index 00000000..052d973d --- /dev/null +++ b/lib/core/flags.ts @@ -0,0 +1,57 @@ +const FLAG_KEYS = [ + "optableDebug", + "optableDisableConsent", + "optableResolve1P", + "optableResolve3P", + "optableEnableAnalytics", + "optableControlGroup", + "optableForceTargeting", + "optableForceGlobalRouting", + "optableForceSkipMerge", +] as const; + +export type FlagKey = (typeof FLAG_KEYS)[number]; +export type Flags = Partial>; + +function parseFlags(): Flags { + const flags: Flags = {}; + + try { + const params = new URLSearchParams(window.location.search); + for (const key of FLAG_KEYS) { + if (params.has(key)) { + flags[key] = params.get(key) || "1"; + } + } + } catch { + // URL params unavailable + } + + try { + for (const key of FLAG_KEYS) { + if (!(key in flags)) { + const val = sessionStorage.getItem(key); + if (val !== null) { + flags[key] = val; + } + } + } + } catch { + // sessionStorage unavailable + } + + return flags; +} + +let _flags: Flags | null = null; + +export function getFlags(): Flags { + if (!_flags) { + _flags = parseFlags(); + } + return _flags; +} + +export function resetFlags(): void { + _flags = null; +} diff --git a/lib/core/prebid/rtd.ts b/lib/core/prebid/rtd.ts index 2cab1e2d..5f035f51 100644 --- a/lib/core/prebid/rtd.ts +++ b/lib/core/prebid/rtd.ts @@ -1,4 +1,5 @@ // RTD (Real-Time Data) module for Prebid.js integration +import { getFlags } from "../flags"; // Type definitions interface EID { @@ -370,19 +371,20 @@ function liveIntentUID2(ortb2: ORTB2): boolean { } function buildRTD(options: RTDOptions = {}): RTDConfig { - if (sessionStorage.optableForceGlobalRouting || options.forceGlobalRouting) { + const flags = getFlags(); + if (flags.optableForceGlobalRouting || options.forceGlobalRouting) { forceGlobalRouting(); } return { - enableLogging: sessionStorage.optableDebug ?? options.enableLogging ?? false, + enableLogging: !!flags.optableDebug || (options.enableLogging ?? false), log(level: string, message: string, ...args: any[]) { if (this.enableLogging) { log(level, message, ...args); } }, eidSources: options.eidSources ?? { ...defaultEIDSources }, - skipMerge: sessionStorage.optableForceSkipMerge + skipMerge: flags.optableForceSkipMerge ? () => true : options.skipMerge !== undefined ? options.skipMerge diff --git a/lib/edge/abTest.ts b/lib/edge/abTest.ts new file mode 100644 index 00000000..a072a24b --- /dev/null +++ b/lib/edge/abTest.ts @@ -0,0 +1,23 @@ +import type { ABTestConfig } from "../config"; + +export function determineABTest(abTests?: ABTestConfig[]): ABTestConfig | null { + if (!abTests || abTests.length === 0) { + return null; + } + + const totalTrafficPercentage = abTests.reduce((sum, test) => sum + test.trafficPercentage, 0); + if (totalTrafficPercentage > 100) { + console.error(`AB Test Config Error: Traffic Percentage Sum Exceeds 100%`); + return null; + } + + const bucket = Math.floor(Math.random() * 100); + let cumulative = 0; + for (const test of abTests) { + cumulative += test.trafficPercentage; + if (bucket < cumulative) { + return test; + } + } + return null; +} diff --git a/lib/edge/targeting.test.js b/lib/edge/targeting.test.js index 8d6356b0..3c54d2cd 100644 --- a/lib/edge/targeting.test.js +++ b/lib/edge/targeting.test.js @@ -70,176 +70,6 @@ describe("SkipTargetingForBots", () => { }); }); -describe("determineABTest", () => { - // Mock Math.random to control the random bucket selection - let originalMathRandom; - - beforeEach(() => { - originalMathRandom = Math.random; - }); - - afterEach(() => { - Math.random = originalMathRandom; - }); - - test("returns null when no abTests provided", () => { - const { determineABTest } = require("./targeting"); - expect(determineABTest()).toBeNull(); - expect(determineABTest(null)).toBeNull(); - expect(determineABTest([])).toBeNull(); - }); - - test("returns null when traffic percentage sum exceeds 100%", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { id: "test1", trafficPercentage: 60 }, - { id: "test2", trafficPercentage: 50 }, - ]; - - // Mock console.error to capture the error message - const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); - - expect(determineABTest(abTests)).toBeNull(); - expect(consoleSpy).toHaveBeenCalledWith("AB Test Config Error: Traffic Percentage Sum Exceeds 100%"); - - consoleSpy.mockRestore(); - }); - - test("returns correct test based on random bucket", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { id: "test1", trafficPercentage: 30 }, - { id: "test2", trafficPercentage: 40 }, - { id: "test3", trafficPercentage: 20 }, - ]; - - // Test bucket 0-29 (first test) - Math.random = jest.fn().mockReturnValue(0.15); // bucket = 15 - expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 30 }); - - // Test bucket 30-69 (second test) - Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50 - expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 40 }); - - // Test bucket 70-89 (third test) - Math.random = jest.fn().mockReturnValue(0.8); // bucket = 80 - expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 20 }); - - // Test bucket 90-99 (no test selected) - Math.random = jest.fn().mockReturnValue(0.95); // bucket = 95 - expect(determineABTest(abTests)).toBeNull(); - }); - - test("handles single test configuration", () => { - const { determineABTest } = require("./targeting"); - const abTests = [{ id: "single-test", trafficPercentage: 50 }]; - - // Test bucket 0-49 (test selected) - Math.random = jest.fn().mockReturnValue(0.25); // bucket = 25 - expect(determineABTest(abTests)).toEqual({ id: "single-test", trafficPercentage: 50 }); - - // Test bucket 50-99 (no test selected) - Math.random = jest.fn().mockReturnValue(0.75); // bucket = 75 - expect(determineABTest(abTests)).toBeNull(); - }); - - test("handles edge cases with 0% traffic", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { id: "test1", trafficPercentage: 0 }, - { id: "test2", trafficPercentage: 100 }, - ]; - - // Any bucket should return test2 since test1 has 0% traffic - Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50 - expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 100 }); - }); - - test("handles tests with matcher_override", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { - id: "test1", - trafficPercentage: 50, - matcher_override: [{ id: "override1", rank: 1 }], - skipMatchers: ["1p"], - }, - { - id: "test2", - trafficPercentage: 50, - matcher_override: [{ id: "override2", rank: 2 }], - skipMatchers: ["1p"], - }, - ]; - - Math.random = jest.fn().mockReturnValue(0.25); // bucket = 25 - expect(determineABTest(abTests)).toEqual({ - id: "test1", - trafficPercentage: 50, - matcher_override: [{ id: "override1", rank: 1 }], - skipMatchers: ["1p"], - }); - }); - - test("handles tests with skipResolvers", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { - id: "test1", - trafficPercentage: 50, - skipResolvers: ["resolver1"], - }, - { - id: "test2", - trafficPercentage: 50, - skipResolvers: ["resolver2"], - }, - ]; - - Math.random = jest.fn().mockReturnValue(0.25); // bucket = 25 - expect(determineABTest(abTests)).toEqual({ - id: "test1", - trafficPercentage: 50, - skipResolvers: ["resolver1"], - }); - }); - - test("handles boundary conditions", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { id: "test1", trafficPercentage: 50 }, - { id: "test2", trafficPercentage: 50 }, - ]; - - // Test exact boundary at 50 - Math.random = jest.fn().mockReturnValue(0.5); // bucket = 50 - expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 50 }); - - // Test just below boundary - Math.random = jest.fn().mockReturnValue(0.499); // bucket = 49 - expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 50 }); - }); - - test("handles floating point precision issues", () => { - const { determineABTest } = require("./targeting"); - const abTests = [ - { id: "test1", trafficPercentage: 33.33 }, - { id: "test2", trafficPercentage: 33.33 }, - { id: "test3", trafficPercentage: 33.34 }, - ]; - - // Test cumulative calculation with floating point - Math.random = jest.fn().mockReturnValue(0.333); // bucket = 33 - expect(determineABTest(abTests)).toEqual({ id: "test1", trafficPercentage: 33.33 }); - - Math.random = jest.fn().mockReturnValue(0.666); // bucket = 66 - expect(determineABTest(abTests)).toEqual({ id: "test2", trafficPercentage: 33.33 }); - - Math.random = jest.fn().mockReturnValue(0.999); // bucket = 99 - expect(determineABTest(abTests)).toEqual({ id: "test3", trafficPercentage: 33.34 }); - }); -}); - describe("Targeting function handles optional params like targeting signals and skipMatchers", () => { let originalWindow; let originalLocation; diff --git a/lib/edge/targeting.ts b/lib/edge/targeting.ts index 9fe95ee2..b763dce7 100644 --- a/lib/edge/targeting.ts +++ b/lib/edge/targeting.ts @@ -1,4 +1,5 @@ -import type { ResolvedConfig, ABTestConfig, MatcherOverride } from "../config"; +import type { ResolvedConfig, MatcherOverride } from "../config"; +import { determineABTest } from "./abTest"; import { fetch } from "../core/network"; import { LocalStorage } from "../core/storage"; import { isBot } from "../addons/botDetection"; @@ -43,31 +44,6 @@ type TargetingResponse = { const TARGETING_DONE_KEY = "OPTABLE_TARGETING_DONE"; -// Determine which A/B test (if any) should be used for this request -function determineABTest(abTests?: ABTestConfig[]): ABTestConfig | null { - if (!abTests || abTests.length === 0) { - return null; - } - - // Skip A/B testing if traffic percentage sum exceeds 100% - const totalTrafficPercentage = abTests.reduce((sum, test) => sum + test.trafficPercentage, 0); - if (totalTrafficPercentage > 100) { - console.error(`AB Test Config Error: Traffic Percentage Sum Exceeds 100%`); - return null; - } - - // Simple random number 0-99 - const bucket = Math.floor(Math.random() * 100); - let cumulative = 0; - for (const test of abTests) { - cumulative += test.trafficPercentage; - if (bucket < cumulative) { - return test; - } - } - return null; -} - async function Targeting(config: ResolvedConfig, req: TargetingRequest): Promise { const searchParams = new URLSearchParams(); req.ids.forEach((id) => searchParams.append("id", id)); @@ -189,6 +165,6 @@ function TargetingKeyValues(tdata: TargetingResponse | null): TargetingKeyValues return result; } -export { Targeting, TargetingFromCache, TargetingClearCache, PrebidORTB2, TargetingKeyValues, determineABTest }; +export { Targeting, TargetingFromCache, TargetingClearCache, PrebidORTB2, TargetingKeyValues }; export default Targeting; -export type { TargetingResponse, TargetingRequest, ABTestConfig, MatcherOverride }; +export type { TargetingResponse, TargetingRequest, MatcherOverride };