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..3c54d2c 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 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 };