Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions lib/addons/abTestAssignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ 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%
{ id: "production" }, // treatment — gets remaining traffic (95%)
{ id: "test", trafficPercentage: 5 }, // control — 5%
],
});

Expand All @@ -31,7 +31,7 @@ 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 }],
variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }],
pbjs, // hooks registered automatically
});

Expand All @@ -42,7 +42,7 @@ 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 }] });
const ab = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
window.pbjs.que.push(() => ab.setHooks(window.pbjs));
```

Expand Down Expand Up @@ -85,8 +85,8 @@ const ab = setupAB({
| ------------- | ----------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `controlId` | `string` | `"test"` | The variant `id` considered the control group. Used to resolve `isControl` and the `optableControlGroup` flag override. |
| `treatmentId` | `string` | `"production"` | 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. |

Expand Down
54 changes: 27 additions & 27 deletions lib/addons/abTestAssignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ afterEach(() => {
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");
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("production");
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");
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("test");
expect(result.isControl).toBe(true);
});

Expand All @@ -41,27 +41,27 @@ describe("setupAB - traffic assignment", () => {

describe("setupAB - localStorage stickiness", () => {
it("persists the assignment to localStorage", () => {
setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] });
setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.id).toBe("all");
expect(stored.id).toBe("production");
});

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");
localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: "test", trafficPercentage: 5 }));
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("test");
});

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");
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("production");
});

it("respects a custom storageKey", () => {
const key = "MY_AB_TEST";
setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }], storageKey: key });
setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }], storageKey: key });
expect(localStorage.getItem(key)).not.toBeNull();
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
Expand All @@ -71,16 +71,16 @@ 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");
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("test");
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");
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(result.variant.id).toBe("production");
expect(result.isControl).toBe(false);
});
});
Expand All @@ -105,7 +105,7 @@ describe("setupAB - control group cache clearing", () => {
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 }] });
setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
expect(localStorage.getItem("OPTABLE_RESOLVED")).toBeNull();
expect(localStorage.getItem("OPTABLE_TARGETING_abc123")).toBeNull();
expect(localStorage.getItem("OPTABLE_TARGETING_def456")).toBeNull();
Expand All @@ -115,55 +115,55 @@ describe("setupAB - control group cache clearing", () => {
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 }] });
setupAB({ variants: [{ id: "production" }, { id: "test", 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 });
setupAB({ variants: [{ id: "production" }, { id: "test", 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 }] });
setupAB({ variants: [{ id: "production" }, { id: "test", 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 }] });
const result = setupAB({ variants: [{ id: "production" }, { id: "test", 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 result = setupAB({ variants: [{ id: "production" }, { id: "test", 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 result = setupAB({ variants: [{ id: "production" }, { id: "test", 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");
expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("production");
});

it("stamps bids and does not overwrite an existing splitTestAssignment", () => {
const result = setupAB({ variants: [{ id: "all" }, { id: "none", trafficPercentage: 5 }] });
const result = setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }] });
const bid: any = { bidId: "bid-1", ortb2Imp: { ext: { optable: { splitTestAssignment: "control" } } } };
const pbjs = {
getEvents: () => [{ eventType: "auctionEnd", args: { bidderRequests: [{ bids: [bid] }] } }],
Expand All @@ -173,9 +173,9 @@ describe("setupAB - setHooks", () => {
expect(bid.ortb2Imp.ext.optable.splitTestAssignment).toBe("control");
});

it("registers hooks automatically when pbjs is passed to setupAB", () => {
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 });
setupAB({ variants: [{ id: "production" }, { id: "test", trafficPercentage: 5 }], pbjs });
expect(pbjs.onEvent).toHaveBeenCalledWith("auctionEnd", expect.any(Function));
});
});
Expand Down
13 changes: 10 additions & 3 deletions lib/addons/abTestAssignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export interface ABTestVariant {
export interface SetupABConfig {
variants: ABTestVariant[];
storageKey?: string;
// The variant id treated as "control" (Optable disabled). Defaults to 'none'.
// The variant id treated as "control" (Optable disabled). Defaults to 'test'.
controlId?: string;
// The variant id treated as "treatment" (Optable enabled). Defaults to 'all'.
// The variant id treated as "treatment" (Optable enabled). Defaults to 'production'.
treatmentId?: string;
// An initialized SDK instance. When provided, targetingClearCache() is used
// for precise cache clearing in the control group instead of a prefix scan.
Expand Down Expand Up @@ -42,7 +42,14 @@ function fillTrafficPercentages(variants: ABTestVariant[]): ABTestConfig[] {
}

export function setupAB(config: SetupABConfig): ABTestSetupResult {
const { variants, storageKey = DEFAULT_STORAGE_KEY, controlId = "none", treatmentId = "all", sdk, pbjs } = config;
const {
variants,
storageKey = DEFAULT_STORAGE_KEY,
controlId = "test",
treatmentId = "production",
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.
Expand Down
Loading