From b9818c749e9aa8966ea00312fcd9b1133911d066 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Fri, 17 Jul 2026 15:01:30 -0600 Subject: [PATCH 1/7] feat(addons): add abTestAssignment addon with setupAB and move determineABTest to edge/abTest - Add lib/edge/abTest.ts with determineABTest (moved from targeting.ts) - Add lib/addons/abTestAssignment.ts with setupAB: handles variant assignment, localStorage stickiness, sessionStorage override, and prebid bid stamping via applyToAuctionEvent/setHooks - Remove determineABTest and ABTestConfig re-export from targeting.ts - Move determineABTest tests from targeting.test.js to abTestAssignment.test.ts - Add usage README at lib/addons/abTestAssignment.md Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.md | 108 +++++++++++ lib/addons/abTestAssignment.test.ts | 268 ++++++++++++++++++++++++++++ lib/addons/abTestAssignment.ts | 122 +++++++++++++ lib/edge/abTest.ts | 23 +++ lib/edge/targeting.test.js | 169 ------------------ lib/edge/targeting.ts | 32 +--- 6 files changed, 525 insertions(+), 197 deletions(-) create mode 100644 lib/addons/abTestAssignment.md create mode 100644 lib/addons/abTestAssignment.test.ts create mode 100644 lib/addons/abTestAssignment.ts create mode 100644 lib/edge/abTest.ts diff --git a/lib/addons/abTestAssignment.md b/lib/addons/abTestAssignment.md new file mode 100644 index 0000000..92e16b4 --- /dev/null +++ b/lib/addons/abTestAssignment.md @@ -0,0 +1,108 @@ +# 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 + +Call `ab.setHooks(pbjs)` **before** `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 }], +}); + +const analytics = new OptablePrebidAnalytics(sdkInstance, { analytics: true }); + +ab.setHooks(pbjs); // stamps ortb2Imp.ext.optable.splitTestAssignment on bids +analytics.hookIntoPrebid(); // reads splitTestAssignment from bids when recording auctions +``` + +### 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. | +| `sessionOverrideKey` | `string` | `"optableControlGroup"` | `sessionStorage` key checked for a manual override. Set to `"1"` to force control, `"0"` to force treatment. | +| `controlId` | `string` | `"none"` | The variant `id` considered the control group. Used to resolve `isControl` and the `sessionStorage` override. | +| `treatmentId` | `string` | `"all"` | The variant `id` considered the treatment group. Used to resolve `isControl` and the `sessionStorage` override. | + +**Returned object** + +| Property | Type | Description | +| ------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `variant` | `ABTestConfig` | The assigned variant (`id` + `trafficPercentage`). | +| `isControl` | `boolean` | `true` when the assigned variant is not the treatment. | +| `getSplitTestAssignment` | `() => string` | Returns the assigned variant id. | +| `applyToAuctionEvent` | `(event) => void` | Stamps `splitTestAssignment` onto all bids in a Prebid `auctionEnd` event object. Skips bids that already have a value set. | +| `setHooks` | `(pbjs) => void` | Registers a Prebid `onEvent("auctionEnd", ...)` handler and replays any past events so all auctions are stamped. | + +## Overriding the assignment for testing + +Set `sessionStorage.optableControlGroup` before the page loads: + +```js +// Force control group +sessionStorage.setItem("optableControlGroup", "1"); + +// Force treatment group +sessionStorage.setItem("optableControlGroup", "0"); +``` + +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 0000000..ba21bf3 --- /dev/null +++ b/lib/addons/abTestAssignment.test.ts @@ -0,0 +1,268 @@ +import { setupAB } from "./abTestAssignment.ts"; +import { determineABTest } from "../edge/abTest"; + +const STORAGE_KEY = "OPTABLE_SPLIT_TEST"; +const SESSION_OVERRIDE_KEY = "optableControlGroup"; + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + 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 - sessionStorage override", () => { + it("forces control when sessionStorage override is '1'", () => { + sessionStorage.setItem(SESSION_OVERRIDE_KEY, "1"); + 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 sessionStorage override is '0'", () => { + sessionStorage.setItem(SESSION_OVERRIDE_KEY, "0"); + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.variant.id).toBe("all"); + expect(result.isControl).toBe(false); + }); + + it("respects a custom sessionOverrideKey", () => { + sessionStorage.setItem("myOverride", "1"); + const result = setupAB({ + variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], + sessionOverrideKey: "myOverride", + }); + expect(result.variant.id).toBe("none"); + }); +}); + +describe("setupAB - custom variant ids", () => { + it("uses controlId and treatmentId to resolve isControl and overrides", () => { + sessionStorage.setItem(SESSION_OVERRIDE_KEY, "0"); + 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 - getSplitTestAssignment", () => { + it("returns the variant id", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(result.getSplitTestAssignment()).toBe(result.variant.id); + }); +}); + +describe("setupAB - applyToAuctionEvent", () => { + it("stamps splitTestAssignment onto every bid in bidderRequests", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const bidA: any = { bidId: "bid-1" }; + const bidB: any = { bidId: "bid-2" }; + result.applyToAuctionEvent({ bidderRequests: [{ bids: [bidA, bidB] }] }); + expect(bidA.ortb2Imp.ext.optable.splitTestAssignment).toBe("all"); + expect(bidB.ortb2Imp.ext.optable.splitTestAssignment).toBe("all"); + }); + + it("does not overwrite a splitTestAssignment already set on a bid", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { splitTestAssignment: "control" } } } }; + result.applyToAuctionEvent({ bidderRequests: [{ bids: [bid] }] }); + expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("control"); + }); + + it("preserves other fields on ortb2Imp.ext.optable", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { foo: "bar" } } } }; + result.applyToAuctionEvent({ bidderRequests: [{ bids: [bid] }] }); + expect(bid.ortb2Imp.ext.optable).toEqual({ foo: "bar", splitTestAssignment: "all" }); + }); + + it("handles a missing bidderRequests gracefully", () => { + const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); + expect(() => result.applyToAuctionEvent({})).not.toThrow(); + }); +}); + +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"); + }); +}); + +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 0000000..15dde2c --- /dev/null +++ b/lib/addons/abTestAssignment.ts @@ -0,0 +1,122 @@ +import type { ABTestConfig } from "../config"; +import { determineABTest } from "../edge/abTest"; + +const DEFAULT_STORAGE_KEY = "OPTABLE_SPLIT_TEST"; +const DEFAULT_SESSION_OVERRIDE_KEY = "optableControlGroup"; + +export interface ABTestVariant { + id: string; + trafficPercentage?: number; +} + +export interface SetupABConfig { + variants: ABTestVariant[]; + storageKey?: string; + sessionOverrideKey?: 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; +} + +export interface ABTestSetupResult { + variant: ABTestConfig; + isControl: boolean; + getSplitTestAssignment: () => string; + // Stamps splitTestAssignment onto all bids in a prebid auctionEnd event object. + applyToAuctionEvent: (event: { bidderRequests?: any[] }) => void; + // Registers a prebid onEvent hook so every future auction is stamped automatically. + // Call this before analytics.setHooks(pbjs) so the value is present when analytics reads it. + 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, + sessionOverrideKey = DEFAULT_SESSION_OVERRIDE_KEY, + controlId = "none", + treatmentId = "all", + } = config; + + const filled = fillTrafficPercentages(variants); + + let selected: ABTestConfig | null = null; + + try { + const override = sessionStorage.getItem(sessionOverrideKey); + if (override === "1") { + selected = filled.find((v) => v.id === controlId) ?? { id: controlId, trafficPercentage: 0 }; + } else if (override === "0") { + selected = filled.find((v) => v.id === treatmentId) ?? { id: treatmentId, trafficPercentage: 0 }; + } + } catch { + // sessionStorage unavailable + } + + 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 + } + } + + if (!selected) { + selected = determineABTest(filled) ?? filled[0]; + } + + try { + localStorage.setItem(storageKey, JSON.stringify(selected)); + } catch { + // localStorage unavailable + } + + const isControl = selected.id !== treatmentId; + const assignment = selected.id; + + 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(pbjs: any): void { + pbjs.getEvents().forEach((event: any) => { + if (event.eventType === "auctionEnd") { + applyToAuctionEvent(event.args); + } + }); + pbjs.onEvent("auctionEnd", applyToAuctionEvent); + } + + return { + variant: selected, + isControl, + getSplitTestAssignment: () => assignment, + applyToAuctionEvent, + setHooks, + }; +} diff --git a/lib/edge/abTest.ts b/lib/edge/abTest.ts new file mode 100644 index 0000000..a072a24 --- /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 8d6356b..1c8f019 100644 --- a/lib/edge/targeting.test.js +++ b/lib/edge/targeting.test.js @@ -70,175 +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; diff --git a/lib/edge/targeting.ts b/lib/edge/targeting.ts index 9fe95ee..b763dce 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 }; From c2190ccd6179c11e0b39abd6b3dda8ff56d52889 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Fri, 17 Jul 2026 15:05:11 -0600 Subject: [PATCH 2/7] style: fix prettier formatting in targeting.test.js Co-Authored-By: Claude Sonnet 4.6 --- lib/edge/targeting.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/edge/targeting.test.js b/lib/edge/targeting.test.js index 1c8f019..3c54d2c 100644 --- a/lib/edge/targeting.test.js +++ b/lib/edge/targeting.test.js @@ -70,7 +70,6 @@ describe("SkipTargetingForBots", () => { }); }); - describe("Targeting function handles optional params like targeting signals and skipMatchers", () => { let originalWindow; let originalLocation; From e868f9d25f7df37399519116a02df1b2ad0a9e89 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 20 Jul 2026 11:01:01 -0600 Subject: [PATCH 3/7] feat(core): add flags module and remove direct sessionStorage/URL reads from modules - Add lib/core/flags.ts: single place that reads URL params (with sessionStorage fallback) into a Flags object; lazy singleton, reset via resetFlags() for tests - Update lib/core/prebid/rtd.ts to read optableDebug, optableForceGlobalRouting, and optableForceSkipMerge from getFlags() instead of sessionStorage directly - Update lib/addons/abTestAssignment.ts to read optableControlGroup from getFlags() and remove sessionOverrideKey config option - Add lib/core/flags.test.ts Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.md | 31 ++++++------ lib/addons/abTestAssignment.test.ts | 29 +++++------ lib/addons/abTestAssignment.ts | 25 +++------- lib/core/flags.test.ts | 74 +++++++++++++++++++++++++++++ lib/core/flags.ts | 57 ++++++++++++++++++++++ lib/core/prebid/rtd.ts | 8 ++-- 6 files changed, 172 insertions(+), 52 deletions(-) create mode 100644 lib/core/flags.test.ts create mode 100644 lib/core/flags.ts diff --git a/lib/addons/abTestAssignment.md b/lib/addons/abTestAssignment.md index 92e16b4..0653c88 100644 --- a/lib/addons/abTestAssignment.md +++ b/lib/addons/abTestAssignment.md @@ -75,13 +75,12 @@ const ab = setupAB({ **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. | -| `sessionOverrideKey` | `string` | `"optableControlGroup"` | `sessionStorage` key checked for a manual override. Set to `"1"` to force control, `"0"` to force treatment. | -| `controlId` | `string` | `"none"` | The variant `id` considered the control group. Used to resolve `isControl` and the `sessionStorage` override. | -| `treatmentId` | `string` | `"all"` | The variant `id` considered the treatment group. Used to resolve `isControl` and the `sessionStorage` override. | +| 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. | **Returned object** @@ -95,14 +94,18 @@ const ab = setupAB({ ## Overriding the assignment for testing -Set `sessionStorage.optableControlGroup` before the page loads: +Add `optableControlGroup` to the page URL: -```js -// Force control group -sessionStorage.setItem("optableControlGroup", "1"); +``` +https://example.com/page?optableControlGroup=1 # force control +https://example.com/page?optableControlGroup=0 # force treatment +``` -// Force treatment group -sessionStorage.setItem("optableControlGroup", "0"); +Or set it in `sessionStorage` before the SDK initializes: + +```js +sessionStorage.setItem("optableControlGroup", "1"); // force control +sessionStorage.setItem("optableControlGroup", "0"); // force treatment ``` -Clear `localStorage.OPTABLE_SPLIT_TEST` to reset a sticky assignment. +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 index ba21bf3..7e9a286 100644 --- a/lib/addons/abTestAssignment.test.ts +++ b/lib/addons/abTestAssignment.test.ts @@ -1,12 +1,13 @@ import { setupAB } from "./abTestAssignment.ts"; import { determineABTest } from "../edge/abTest"; +import { resetFlags } from "../core/flags"; const STORAGE_KEY = "OPTABLE_SPLIT_TEST"; -const SESSION_OVERRIDE_KEY = "optableControlGroup"; beforeEach(() => { localStorage.clear(); sessionStorage.clear(); + resetFlags(); jest.spyOn(Math, "random").mockReturnValue(0.5); }); @@ -66,34 +67,28 @@ describe("setupAB - localStorage stickiness", () => { }); }); -describe("setupAB - sessionStorage override", () => { - it("forces control when sessionStorage override is '1'", () => { - sessionStorage.setItem(SESSION_OVERRIDE_KEY, "1"); +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 sessionStorage override is '0'", () => { - sessionStorage.setItem(SESSION_OVERRIDE_KEY, "0"); + 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); }); - - it("respects a custom sessionOverrideKey", () => { - sessionStorage.setItem("myOverride", "1"); - const result = setupAB({ - variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], - sessionOverrideKey: "myOverride", - }); - expect(result.variant.id).toBe("none"); - }); }); describe("setupAB - custom variant ids", () => { - it("uses controlId and treatmentId to resolve isControl and overrides", () => { - sessionStorage.setItem(SESSION_OVERRIDE_KEY, "0"); + 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", diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts index 15dde2c..b38f4d5 100644 --- a/lib/addons/abTestAssignment.ts +++ b/lib/addons/abTestAssignment.ts @@ -1,8 +1,8 @@ import type { ABTestConfig } from "../config"; import { determineABTest } from "../edge/abTest"; +import { getFlags } from "../core/flags"; const DEFAULT_STORAGE_KEY = "OPTABLE_SPLIT_TEST"; -const DEFAULT_SESSION_OVERRIDE_KEY = "optableControlGroup"; export interface ABTestVariant { id: string; @@ -12,7 +12,6 @@ export interface ABTestVariant { export interface SetupABConfig { variants: ABTestVariant[]; storageKey?: string; - sessionOverrideKey?: 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'. @@ -41,27 +40,17 @@ function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] { } export function setupAB(config: SetupABConfig): ABTestSetupResult { - const { - variants, - storageKey = DEFAULT_STORAGE_KEY, - sessionOverrideKey = DEFAULT_SESSION_OVERRIDE_KEY, - controlId = "none", - treatmentId = "all", - } = config; + const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all" } = config; const filled = fillTrafficPercentages(variants); let selected: ABTestConfig | null = null; - try { - const override = sessionStorage.getItem(sessionOverrideKey); - if (override === "1") { - selected = filled.find((v) => v.id === controlId) ?? { id: controlId, trafficPercentage: 0 }; - } else if (override === "0") { - selected = filled.find((v) => v.id === treatmentId) ?? { id: treatmentId, trafficPercentage: 0 }; - } - } catch { - // sessionStorage unavailable + 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 }; } if (!selected) { diff --git a/lib/core/flags.test.ts b/lib/core/flags.test.ts new file mode 100644 index 0000000..2994d46 --- /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 0000000..052d973 --- /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 2cab1e2..5f035f5 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 From c94d6c08d58619ed1f78449c73b95017685f2365 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 20 Jul 2026 11:18:09 -0600 Subject: [PATCH 4/7] docs(abTestAssignment): add flow comments explaining override, cache, and first-visit assignment Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts index b38f4d5..f66c061 100644 --- a/lib/addons/abTestAssignment.ts +++ b/lib/addons/abTestAssignment.ts @@ -42,10 +42,15 @@ function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] { export function setupAB(config: SetupABConfig): ABTestSetupResult { const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all" } = 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 }; @@ -53,6 +58,11 @@ export function setupAB(config: SetupABConfig): ABTestSetupResult { 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); @@ -67,10 +77,15 @@ export function setupAB(config: SetupABConfig): ABTestSetupResult { } } + // 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 { From a2d96cfb21f33ca6b8a053af6e09955dc1e7acd9 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 20 Jul 2026 11:20:31 -0600 Subject: [PATCH 5/7] fix(abTestAssignment): clear targeting cache for control group users Prevents stale Optable targeting data from a previous session being served to users who are subsequently assigned to the control group. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.test.ts | 30 +++++++++++++++++++++++++++++ lib/addons/abTestAssignment.ts | 14 ++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/lib/addons/abTestAssignment.test.ts b/lib/addons/abTestAssignment.test.ts index 7e9a286..a05852f 100644 --- a/lib/addons/abTestAssignment.test.ts +++ b/lib/addons/abTestAssignment.test.ts @@ -99,6 +99,36 @@ describe("setupAB - custom variant ids", () => { }); }); +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("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 - getSplitTestAssignment", () => { it("returns the variant id", () => { const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts index f66c061..150e1d0 100644 --- a/lib/addons/abTestAssignment.ts +++ b/lib/addons/abTestAssignment.ts @@ -95,6 +95,20 @@ export function setupAB(config: SetupABConfig): ABTestSetupResult { 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"); + 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) => { From acfe6f0acb35980fe4a7f37a88d1fbcd4becae32 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 20 Jul 2026 11:26:07 -0600 Subject: [PATCH 6/7] fix(abTestAssignment): use sdk.targetingClearCache() for control group cache clearing When an SDK instance is provided, use its targetingClearCache() method for precise key removal instead of a localStorage prefix scan. Falls back to the prefix scan when no SDK instance is available. Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.test.ts | 7 +++++++ lib/addons/abTestAssignment.ts | 15 +++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/addons/abTestAssignment.test.ts b/lib/addons/abTestAssignment.test.ts index a05852f..068b753 100644 --- a/lib/addons/abTestAssignment.test.ts +++ b/lib/addons/abTestAssignment.test.ts @@ -120,6 +120,13 @@ describe("setupAB - control group cache clearing", () => { 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"); diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts index 150e1d0..f53b9b1 100644 --- a/lib/addons/abTestAssignment.ts +++ b/lib/addons/abTestAssignment.ts @@ -16,6 +16,9 @@ export interface SetupABConfig { 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 }; } export interface ABTestSetupResult { @@ -40,7 +43,7 @@ function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] { } export function setupAB(config: SetupABConfig): ABTestSetupResult { - const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all" } = config; + const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all", sdk } = config; // Process the provided variant config so that every variant has an explicit traffic percentage. // Variants without one share the remaining percentage equally. @@ -101,9 +104,13 @@ export function setupAB(config: SetupABConfig): ABTestSetupResult { if (isControl) { try { localStorage.removeItem("OPTABLE_RESOLVED"); - Object.keys(localStorage) - .filter((k) => k.startsWith("OPTABLE_TARGETING_")) - .forEach((k) => localStorage.removeItem(k)); + if (sdk) { + sdk.targetingClearCache(); + } else { + Object.keys(localStorage) + .filter((k) => k.startsWith("OPTABLE_TARGETING_")) + .forEach((k) => localStorage.removeItem(k)); + } } catch { // localStorage unavailable } From 537c3d1525c2e968bbd250ec74621189ee57bad6 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 20 Jul 2026 11:51:43 -0600 Subject: [PATCH 7/7] refactor(abTestAssignment): simplify public API based on review feedback - Replace getSplitTestAssignment() function with splitTestAssignment string - Remove applyToAuctionEvent from public API (internal to setHooks) - Accept pbjs in SetupABConfig to register hooks automatically at setup time; setHooks() remains on result for deferred registration when pbjs is not yet available Co-Authored-By: Claude Sonnet 4.6 --- lib/addons/abTestAssignment.md | 27 +++++++++------ lib/addons/abTestAssignment.test.ts | 53 +++++++++++------------------ lib/addons/abTestAssignment.ts | 24 +++++++------ 3 files changed, 50 insertions(+), 54 deletions(-) diff --git a/lib/addons/abTestAssignment.md b/lib/addons/abTestAssignment.md index 0653c88..d79adbd 100644 --- a/lib/addons/abTestAssignment.md +++ b/lib/addons/abTestAssignment.md @@ -24,7 +24,7 @@ if (ab.isControl) { ### With Prebid.js analytics -Call `ab.setHooks(pbjs)` **before** `analytics.hookIntoPrebid()` so bids are stamped before the analytics addon reads them. +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"; @@ -32,12 +32,18 @@ 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: -ab.setHooks(pbjs); // stamps ortb2Imp.ext.optable.splitTestAssignment on bids -analytics.hookIntoPrebid(); // reads splitTestAssignment from bids when recording auctions +```js +const ab = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); +window.pbjs.que.push(() => ab.setHooks(window.pbjs)); ``` ### Custom variant names @@ -81,16 +87,17 @@ const ab = setupAB({ | `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. | -| `getSplitTestAssignment` | `() => string` | Returns the assigned variant id. | -| `applyToAuctionEvent` | `(event) => void` | Stamps `splitTestAssignment` onto all bids in a Prebid `auctionEnd` event object. Skips bids that already have a value set. | -| `setHooks` | `(pbjs) => void` | Registers a Prebid `onEvent("auctionEnd", ...)` handler and replays any past events so all auctions are stamped. | +| 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 diff --git a/lib/addons/abTestAssignment.test.ts b/lib/addons/abTestAssignment.test.ts index 068b753..bd272db 100644 --- a/lib/addons/abTestAssignment.test.ts +++ b/lib/addons/abTestAssignment.test.ts @@ -136,40 +136,10 @@ describe("setupAB - control group cache clearing", () => { }); }); -describe("setupAB - getSplitTestAssignment", () => { - it("returns the variant id", () => { +describe("setupAB - splitTestAssignment", () => { + it("exposes the assigned variant id as a string", () => { const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); - expect(result.getSplitTestAssignment()).toBe(result.variant.id); - }); -}); - -describe("setupAB - applyToAuctionEvent", () => { - it("stamps splitTestAssignment onto every bid in bidderRequests", () => { - const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); - const bidA: any = { bidId: "bid-1" }; - const bidB: any = { bidId: "bid-2" }; - result.applyToAuctionEvent({ bidderRequests: [{ bids: [bidA, bidB] }] }); - expect(bidA.ortb2Imp.ext.optable.splitTestAssignment).toBe("all"); - expect(bidB.ortb2Imp.ext.optable.splitTestAssignment).toBe("all"); - }); - - it("does not overwrite a splitTestAssignment already set on a bid", () => { - const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); - const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { splitTestAssignment: "control" } } } }; - result.applyToAuctionEvent({ bidderRequests: [{ bids: [bid] }] }); - expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("control"); - }); - - it("preserves other fields on ortb2Imp.ext.optable", () => { - const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); - const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { foo: "bar" } } } }; - result.applyToAuctionEvent({ bidderRequests: [{ bids: [bid] }] }); - expect(bid.ortb2Imp.ext.optable).toEqual({ foo: "bar", splitTestAssignment: "all" }); - }); - - it("handles a missing bidderRequests gracefully", () => { - const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] }); - expect(() => result.applyToAuctionEvent({})).not.toThrow(); + expect(result.splitTestAssignment).toBe(result.variant.id); }); }); @@ -191,6 +161,23 @@ describe("setupAB - setHooks", () => { 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", () => { diff --git a/lib/addons/abTestAssignment.ts b/lib/addons/abTestAssignment.ts index f53b9b1..ccbdd8e 100644 --- a/lib/addons/abTestAssignment.ts +++ b/lib/addons/abTestAssignment.ts @@ -19,16 +19,15 @@ export interface SetupABConfig { // 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; - getSplitTestAssignment: () => string; - // Stamps splitTestAssignment onto all bids in a prebid auctionEnd event object. - applyToAuctionEvent: (event: { bidderRequests?: any[] }) => void; - // Registers a prebid onEvent hook so every future auction is stamped automatically. - // Call this before analytics.setHooks(pbjs) so the value is present when analytics reads it. + splitTestAssignment: string; + // For deferred hook registration when pbjs is not yet available at setup time. setHooks: (pbjs: any) => void; } @@ -43,7 +42,7 @@ function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] { } export function setupAB(config: SetupABConfig): ABTestSetupResult { - const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all", sdk } = config; + 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. @@ -128,20 +127,23 @@ export function setupAB(config: SetupABConfig): ABTestSetupResult { }); } - function setHooks(pbjs: any): void { - pbjs.getEvents().forEach((event: any) => { + function setHooks(pbjsInstance: any): void { + pbjsInstance.getEvents().forEach((event: any) => { if (event.eventType === "auctionEnd") { applyToAuctionEvent(event.args); } }); - pbjs.onEvent("auctionEnd", applyToAuctionEvent); + pbjsInstance.onEvent("auctionEnd", applyToAuctionEvent); + } + + if (pbjs) { + setHooks(pbjs); } return { variant: selected, isControl, - getSplitTestAssignment: () => assignment, - applyToAuctionEvent, + splitTestAssignment: assignment, setHooks, }; }