@drip-apex/sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
browserGroupStickyStore,
|
|
4
|
+
clearBrowserGroupStickyStore,
|
|
5
|
+
resolveExclusionGroups,
|
|
6
|
+
createMemoryGroupStickyStore,
|
|
7
|
+
type GroupStickyStore,
|
|
8
|
+
} from "./exclusion-groups";
|
|
9
|
+
import type { Assignment, ExclusionGroupConfig } from "./types";
|
|
10
|
+
|
|
11
|
+
function assignment(experimentId: string): Assignment {
|
|
12
|
+
return {
|
|
13
|
+
experimentId,
|
|
14
|
+
variationId: `${experimentId}-v1`,
|
|
15
|
+
index: 0,
|
|
16
|
+
mutations: [],
|
|
17
|
+
isControl: false,
|
|
18
|
+
hashValue: "0.5",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function group(
|
|
23
|
+
id: string,
|
|
24
|
+
members: Array<[string, number]>,
|
|
25
|
+
epoch = 1,
|
|
26
|
+
name?: string,
|
|
27
|
+
): ExclusionGroupConfig {
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
name,
|
|
31
|
+
epoch,
|
|
32
|
+
members: members.map(([experimentId, share]) => ({ experimentId, share })),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ids(assignments: Assignment[]): string[] {
|
|
37
|
+
return assignments.map((a) => a.experimentId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("resolveExclusionGroups", () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
});
|
|
44
|
+
it("ungrouped behavior is unchanged when there are no groups", () => {
|
|
45
|
+
const store = createMemoryGroupStickyStore();
|
|
46
|
+
const assignments = [assignment("a"), assignment("b")];
|
|
47
|
+
const result = resolveExclusionGroups(assignments, undefined, {
|
|
48
|
+
userId: "u1",
|
|
49
|
+
store,
|
|
50
|
+
});
|
|
51
|
+
expect(result.assignments).toBe(assignments);
|
|
52
|
+
expect(result.decisions).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("keeps both members when only one of them is eligible (no overlap)", () => {
|
|
56
|
+
const store = createMemoryGroupStickyStore();
|
|
57
|
+
const groups = [
|
|
58
|
+
group("g1", [
|
|
59
|
+
["a", 0.5],
|
|
60
|
+
["b", 0.5],
|
|
61
|
+
]),
|
|
62
|
+
];
|
|
63
|
+
// only "a" produced an assignment this pass
|
|
64
|
+
const result = resolveExclusionGroups([assignment("a")], groups, {
|
|
65
|
+
userId: "u1",
|
|
66
|
+
store,
|
|
67
|
+
});
|
|
68
|
+
expect(ids(result.assignments)).toEqual(["a"]);
|
|
69
|
+
expect(result.decisions).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("remembers a lone eligible member when another member overlaps later", () => {
|
|
73
|
+
const store = createMemoryGroupStickyStore();
|
|
74
|
+
const groups = [
|
|
75
|
+
group("g1", [
|
|
76
|
+
["a", 0.4],
|
|
77
|
+
["b", 0.6],
|
|
78
|
+
]),
|
|
79
|
+
];
|
|
80
|
+
resolveExclusionGroups([assignment("a")], groups, {
|
|
81
|
+
userId: "u1",
|
|
82
|
+
store,
|
|
83
|
+
});
|
|
84
|
+
const overlap = resolveExclusionGroups(
|
|
85
|
+
[assignment("a"), assignment("b")],
|
|
86
|
+
groups,
|
|
87
|
+
{ userId: "u1", store },
|
|
88
|
+
);
|
|
89
|
+
expect(overlap.decisions[0]).toMatchObject({
|
|
90
|
+
winnerExperimentId: "a",
|
|
91
|
+
sticky: true,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not let a lone zero-share member become sticky over a positive-share member", () => {
|
|
96
|
+
const store = createMemoryGroupStickyStore();
|
|
97
|
+
const groups = [
|
|
98
|
+
group("g1", [
|
|
99
|
+
["a", 0],
|
|
100
|
+
["b", 1],
|
|
101
|
+
]),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
resolveExclusionGroups([assignment("a")], groups, {
|
|
105
|
+
userId: "u1",
|
|
106
|
+
store,
|
|
107
|
+
});
|
|
108
|
+
expect(store.read("g1")).toBeNull();
|
|
109
|
+
|
|
110
|
+
// Older runtimes may already have persisted this invalid lone selection.
|
|
111
|
+
store.write("g1", { experimentId: "a", epoch: 1 });
|
|
112
|
+
const overlap = resolveExclusionGroups(
|
|
113
|
+
[assignment("a"), assignment("b")],
|
|
114
|
+
groups,
|
|
115
|
+
{ userId: "u1", store },
|
|
116
|
+
);
|
|
117
|
+
expect(overlap.decisions[0]).toMatchObject({
|
|
118
|
+
winnerExperimentId: "b",
|
|
119
|
+
sticky: false,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps exactly one winner when two members overlap", () => {
|
|
124
|
+
const store = createMemoryGroupStickyStore();
|
|
125
|
+
const groups = [
|
|
126
|
+
group("g1", [
|
|
127
|
+
["a", 0.5],
|
|
128
|
+
["b", 0.5],
|
|
129
|
+
]),
|
|
130
|
+
];
|
|
131
|
+
const result = resolveExclusionGroups(
|
|
132
|
+
[assignment("a"), assignment("b"), assignment("c")],
|
|
133
|
+
groups,
|
|
134
|
+
{ userId: "u1", store },
|
|
135
|
+
);
|
|
136
|
+
// c is ungrouped and always kept
|
|
137
|
+
expect(result.assignments).toContainEqual(
|
|
138
|
+
expect.objectContaining({ experimentId: "c" }),
|
|
139
|
+
);
|
|
140
|
+
const grouped = ids(result.assignments).filter(
|
|
141
|
+
(id) => id === "a" || id === "b",
|
|
142
|
+
);
|
|
143
|
+
expect(grouped).toHaveLength(1);
|
|
144
|
+
expect(result.decisions).toHaveLength(1);
|
|
145
|
+
expect(result.decisions[0].winnerExperimentId).toBe(grouped[0]);
|
|
146
|
+
expect(result.decisions[0].suppressedExperimentIds).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is deterministic for the same visitor / group / shares", () => {
|
|
150
|
+
const groups = [
|
|
151
|
+
group("g1", [
|
|
152
|
+
["a", 0.5],
|
|
153
|
+
["b", 0.5],
|
|
154
|
+
]),
|
|
155
|
+
];
|
|
156
|
+
const run = () =>
|
|
157
|
+
resolveExclusionGroups([assignment("a"), assignment("b")], groups, {
|
|
158
|
+
userId: "stable-user",
|
|
159
|
+
store: createMemoryGroupStickyStore(),
|
|
160
|
+
}).decisions[0].winnerExperimentId;
|
|
161
|
+
const first = run();
|
|
162
|
+
for (let i = 0; i < 10; i++) {
|
|
163
|
+
expect(run()).toBe(first);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("honors share weighting across a population of visitors", () => {
|
|
168
|
+
const groups = [
|
|
169
|
+
group("g1", [
|
|
170
|
+
["a", 0.9],
|
|
171
|
+
["b", 0.1],
|
|
172
|
+
]),
|
|
173
|
+
];
|
|
174
|
+
let aWins = 0;
|
|
175
|
+
const total = 400;
|
|
176
|
+
for (let i = 0; i < total; i++) {
|
|
177
|
+
const winner = resolveExclusionGroups(
|
|
178
|
+
[assignment("a"), assignment("b")],
|
|
179
|
+
groups,
|
|
180
|
+
{ userId: `user-${i}`, store: createMemoryGroupStickyStore() },
|
|
181
|
+
).decisions[0].winnerExperimentId;
|
|
182
|
+
if (winner === "a") aWins += 1;
|
|
183
|
+
}
|
|
184
|
+
// ~90% should go to "a"; allow generous tolerance.
|
|
185
|
+
expect(aWins / total).toBeGreaterThan(0.75);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("falls back to equal deterministic weights when every overlapping share is zero", () => {
|
|
189
|
+
const groups = [
|
|
190
|
+
group("g1", [
|
|
191
|
+
["a", 0],
|
|
192
|
+
["b", 0],
|
|
193
|
+
]),
|
|
194
|
+
];
|
|
195
|
+
const winners = new Set<string>();
|
|
196
|
+
for (let i = 0; i < 100; i++) {
|
|
197
|
+
const winner = resolveExclusionGroups(
|
|
198
|
+
[assignment("a"), assignment("b")],
|
|
199
|
+
groups,
|
|
200
|
+
{ userId: `zero-${i}`, store: createMemoryGroupStickyStore() },
|
|
201
|
+
).decisions[0].winnerExperimentId;
|
|
202
|
+
if (winner) winners.add(winner);
|
|
203
|
+
}
|
|
204
|
+
expect(winners).toEqual(new Set(["a", "b"]));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("keeps a sticky winner while it stays eligible across varying conflicting sets", () => {
|
|
208
|
+
const store = createMemoryGroupStickyStore();
|
|
209
|
+
const groups = [
|
|
210
|
+
group("g1", [
|
|
211
|
+
["a", 0.5],
|
|
212
|
+
["b", 0.5],
|
|
213
|
+
]),
|
|
214
|
+
];
|
|
215
|
+
const first = resolveExclusionGroups(
|
|
216
|
+
[assignment("a"), assignment("b")],
|
|
217
|
+
groups,
|
|
218
|
+
{ userId: "u1", store },
|
|
219
|
+
);
|
|
220
|
+
const winner = first.decisions[0].winnerExperimentId!;
|
|
221
|
+
// On a later evaluation both are eligible again → same winner, sticky.
|
|
222
|
+
const second = resolveExclusionGroups(
|
|
223
|
+
[assignment("a"), assignment("b")],
|
|
224
|
+
groups,
|
|
225
|
+
{ userId: "u1", store },
|
|
226
|
+
);
|
|
227
|
+
expect(second.decisions[0].winnerExperimentId).toBe(winner);
|
|
228
|
+
expect(second.decisions[0].sticky).toBe(true);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("reselects when the sticky winner loses eligibility, then re-sticks", () => {
|
|
232
|
+
const store: GroupStickyStore = createMemoryGroupStickyStore();
|
|
233
|
+
const groups = [
|
|
234
|
+
group("g1", [
|
|
235
|
+
["a", 1],
|
|
236
|
+
["b", 0],
|
|
237
|
+
["c", 0],
|
|
238
|
+
]),
|
|
239
|
+
];
|
|
240
|
+
// Force winner "a" first.
|
|
241
|
+
const first = resolveExclusionGroups(
|
|
242
|
+
[assignment("a"), assignment("b")],
|
|
243
|
+
groups,
|
|
244
|
+
{ userId: "u1", store },
|
|
245
|
+
);
|
|
246
|
+
expect(first.decisions[0].winnerExperimentId).toBe("a");
|
|
247
|
+
|
|
248
|
+
// Now "a" is no longer eligible; only b and c overlap → reselect among them.
|
|
249
|
+
const zeroShareGroup = [
|
|
250
|
+
group("g1", [
|
|
251
|
+
["a", 1],
|
|
252
|
+
["b", 0.5],
|
|
253
|
+
["c", 0.5],
|
|
254
|
+
]),
|
|
255
|
+
];
|
|
256
|
+
const second = resolveExclusionGroups(
|
|
257
|
+
[assignment("b"), assignment("c")],
|
|
258
|
+
zeroShareGroup,
|
|
259
|
+
{ userId: "u1", store },
|
|
260
|
+
);
|
|
261
|
+
expect(
|
|
262
|
+
second.decisions[0].winnerExperimentId === "b" ||
|
|
263
|
+
second.decisions[0].winnerExperimentId === "c",
|
|
264
|
+
).toBe(true);
|
|
265
|
+
expect(second.decisions[0].sticky).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("resets stickiness when the group epoch changes", () => {
|
|
269
|
+
const store = createMemoryGroupStickyStore();
|
|
270
|
+
const groupsE1 = [
|
|
271
|
+
group(
|
|
272
|
+
"g1",
|
|
273
|
+
[
|
|
274
|
+
["a", 1],
|
|
275
|
+
["b", 0],
|
|
276
|
+
],
|
|
277
|
+
1,
|
|
278
|
+
),
|
|
279
|
+
];
|
|
280
|
+
const first = resolveExclusionGroups(
|
|
281
|
+
[assignment("a"), assignment("b")],
|
|
282
|
+
groupsE1,
|
|
283
|
+
{ userId: "u1", store },
|
|
284
|
+
);
|
|
285
|
+
expect(first.decisions[0].winnerExperimentId).toBe("a");
|
|
286
|
+
|
|
287
|
+
// Epoch bumps AND shares flip → the stored (epoch 1) selection is ignored.
|
|
288
|
+
const groupsE2 = [
|
|
289
|
+
group(
|
|
290
|
+
"g1",
|
|
291
|
+
[
|
|
292
|
+
["a", 0],
|
|
293
|
+
["b", 1],
|
|
294
|
+
],
|
|
295
|
+
2,
|
|
296
|
+
),
|
|
297
|
+
];
|
|
298
|
+
const second = resolveExclusionGroups(
|
|
299
|
+
[assignment("a"), assignment("b")],
|
|
300
|
+
groupsE2,
|
|
301
|
+
{ userId: "u1", store },
|
|
302
|
+
);
|
|
303
|
+
expect(second.decisions[0].winnerExperimentId).toBe("b");
|
|
304
|
+
expect(second.decisions[0].sticky).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("bypasses exclusion for QA-forced members (preview only)", () => {
|
|
308
|
+
const store = createMemoryGroupStickyStore();
|
|
309
|
+
const groups = [
|
|
310
|
+
group("g1", [
|
|
311
|
+
["a", 0.5],
|
|
312
|
+
["b", 0.5],
|
|
313
|
+
]),
|
|
314
|
+
];
|
|
315
|
+
// "a" is forced; it should always be kept and not compete.
|
|
316
|
+
const result = resolveExclusionGroups(
|
|
317
|
+
[assignment("a"), assignment("b")],
|
|
318
|
+
groups,
|
|
319
|
+
{ userId: "u1", store, forcedExperimentIds: ["a"] },
|
|
320
|
+
);
|
|
321
|
+
expect(ids(result.assignments)).toContain("a");
|
|
322
|
+
expect(ids(result.assignments)).toContain("b");
|
|
323
|
+
expect(result.decisions).toEqual([]);
|
|
324
|
+
expect(store.read("g1")).toBeNull();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("does not read or write prior stickiness when persistence is disabled", () => {
|
|
328
|
+
const store: GroupStickyStore = {
|
|
329
|
+
read: vi.fn(() => ({ experimentId: "a", epoch: 1 })),
|
|
330
|
+
write: vi.fn(),
|
|
331
|
+
};
|
|
332
|
+
const groups = [
|
|
333
|
+
group("g1", [
|
|
334
|
+
["a", 0.5],
|
|
335
|
+
["b", 0.5],
|
|
336
|
+
]),
|
|
337
|
+
];
|
|
338
|
+
resolveExclusionGroups([assignment("a"), assignment("b")], groups, {
|
|
339
|
+
userId: "u1",
|
|
340
|
+
store,
|
|
341
|
+
persist: false,
|
|
342
|
+
});
|
|
343
|
+
expect(store.read).not.toHaveBeenCalled();
|
|
344
|
+
expect(store.write).not.toHaveBeenCalled();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("clears browser group stickiness from localStorage and cookies", () => {
|
|
348
|
+
const removeItem = vi.fn();
|
|
349
|
+
let cookieWrite = "";
|
|
350
|
+
vi.stubGlobal("localStorage", { removeItem });
|
|
351
|
+
vi.stubGlobal("document", {
|
|
352
|
+
get cookie() {
|
|
353
|
+
return "";
|
|
354
|
+
},
|
|
355
|
+
set cookie(value: string) {
|
|
356
|
+
cookieWrite = value;
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
clearBrowserGroupStickyStore();
|
|
361
|
+
|
|
362
|
+
expect(removeItem).toHaveBeenCalledWith("drip_xgroups_v1");
|
|
363
|
+
expect(cookieWrite).toBe(
|
|
364
|
+
"drip_xgroups_v1=;path=/;max-age=0;samesite=lax",
|
|
365
|
+
);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("prefers the edge response cookie over stale localStorage", () => {
|
|
369
|
+
vi.stubGlobal("localStorage", {
|
|
370
|
+
getItem: vi.fn(() => JSON.stringify({ g1: ["stale", 1] })),
|
|
371
|
+
});
|
|
372
|
+
vi.stubGlobal("document", {
|
|
373
|
+
cookie: `drip_xgroups_v1=${encodeURIComponent(
|
|
374
|
+
JSON.stringify({ g1: ["edge-winner", 2] }),
|
|
375
|
+
)}`,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
expect(browserGroupStickyStore.read("g1")).toEqual({
|
|
379
|
+
experimentId: "edge-winner",
|
|
380
|
+
epoch: 2,
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { Assignment, ExclusionGroupConfig } from "./types";
|
|
2
|
+
import { hashToFloat } from "./hash";
|
|
3
|
+
import { chooseVariation } from "./bucket";
|
|
4
|
+
import { deleteCookie, readCookie, writeCookie } from "./guards";
|
|
5
|
+
|
|
6
|
+
const STORAGE_KEY = "drip_xgroups_v1"; // gitleaks:allow — public storage key name, not a credential
|
|
7
|
+
const COOKIE_NAME = "drip_xgroups_v1";
|
|
8
|
+
const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
|
9
|
+
|
|
10
|
+
/** A remembered group decision: which member won and at what group epoch. */
|
|
11
|
+
export type PersistedGroupSelection = {
|
|
12
|
+
experimentId: string;
|
|
13
|
+
epoch: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Pluggable persistence so the runtime uses browser storage and tests use memory. */
|
|
17
|
+
export interface GroupStickyStore {
|
|
18
|
+
read(groupId: string): PersistedGroupSelection | null;
|
|
19
|
+
write(groupId: string, selection: PersistedGroupSelection): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type CompactSelection = [string, number];
|
|
23
|
+
type CompactStore = Record<string, CompactSelection>;
|
|
24
|
+
|
|
25
|
+
function normalizeEpoch(epoch: number | null | undefined): number {
|
|
26
|
+
return Number.isFinite(epoch) ? Math.max(1, Math.floor(epoch as number)) : 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseStore(raw: string | null): CompactStore {
|
|
30
|
+
if (!raw) return {};
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
33
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
34
|
+
? (parsed as CompactStore)
|
|
35
|
+
: {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readLocalStorage(): string | null {
|
|
42
|
+
try {
|
|
43
|
+
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readCompactStore(): CompactStore {
|
|
50
|
+
return {
|
|
51
|
+
...parseStore(readLocalStorage()),
|
|
52
|
+
...parseStore(readCookie(COOKIE_NAME)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeCompactStore(store: CompactStore): void {
|
|
57
|
+
const serialized = JSON.stringify(store);
|
|
58
|
+
try {
|
|
59
|
+
globalThis.localStorage?.setItem(STORAGE_KEY, serialized);
|
|
60
|
+
} catch {
|
|
61
|
+
// best-effort
|
|
62
|
+
}
|
|
63
|
+
writeCookie(COOKIE_NAME, serialized, COOKIE_MAX_AGE_SECONDS);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Browser-backed store (localStorage + cookie mirror) used by the live runtime. */
|
|
67
|
+
export const browserGroupStickyStore: GroupStickyStore = {
|
|
68
|
+
read(groupId) {
|
|
69
|
+
const record = readCompactStore()[groupId];
|
|
70
|
+
return Array.isArray(record) &&
|
|
71
|
+
typeof record[0] === "string" &&
|
|
72
|
+
typeof record[1] === "number"
|
|
73
|
+
? { experimentId: record[0], epoch: record[1] }
|
|
74
|
+
: null;
|
|
75
|
+
},
|
|
76
|
+
write(groupId, selection) {
|
|
77
|
+
const store = readCompactStore();
|
|
78
|
+
store[groupId] = [selection.experimentId, normalizeEpoch(selection.epoch)];
|
|
79
|
+
writeCompactStore(store);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Remove consent-gated group stickiness from both browser persistence planes. */
|
|
84
|
+
export function clearBrowserGroupStickyStore(): void {
|
|
85
|
+
try {
|
|
86
|
+
globalThis.localStorage?.removeItem(STORAGE_KEY);
|
|
87
|
+
} catch {
|
|
88
|
+
// best-effort
|
|
89
|
+
}
|
|
90
|
+
deleteCookie(COOKIE_NAME);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** In-memory store for tests and non-persistent (QA / no-consent) contexts. */
|
|
94
|
+
export function createMemoryGroupStickyStore(): GroupStickyStore {
|
|
95
|
+
const map = new Map<string, PersistedGroupSelection>();
|
|
96
|
+
return {
|
|
97
|
+
read: (groupId) => map.get(groupId) ?? null,
|
|
98
|
+
write: (groupId, selection) =>
|
|
99
|
+
void map.set(groupId, {
|
|
100
|
+
experimentId: selection.experimentId,
|
|
101
|
+
epoch: normalizeEpoch(selection.epoch),
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ExclusionGroupDecision {
|
|
107
|
+
groupId: string;
|
|
108
|
+
/** Eligible, non-forced members that overlapped for this visitor. */
|
|
109
|
+
conflictingExperimentIds: string[];
|
|
110
|
+
winnerExperimentId: string | null;
|
|
111
|
+
suppressedExperimentIds: string[];
|
|
112
|
+
/** True when the winner was carried over from a prior sticky selection. */
|
|
113
|
+
sticky: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ResolveExclusionGroupsResult {
|
|
117
|
+
assignments: Assignment[];
|
|
118
|
+
decisions: ExclusionGroupDecision[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Second pass of the overlap-then-pick model.
|
|
123
|
+
*
|
|
124
|
+
* `assignments` are the pass-one winners (each experiment already evaluated its
|
|
125
|
+
* own status, targeting, exposure, sticky assignment and variation split). Here
|
|
126
|
+
* we detect members of the same exclusion group that BOTH became eligible for
|
|
127
|
+
* this visitor (overlap) and keep exactly one — chosen by a group-deterministic
|
|
128
|
+
* hash weighted by each conflicting member's share, re-normalized across only
|
|
129
|
+
* the conflicting members. A previously assigned member stays sticky while it
|
|
130
|
+
* remains eligible; if it drops out we reselect among the current conflicting
|
|
131
|
+
* members. Forced (QA preview) members bypass exclusion entirely.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveExclusionGroups(
|
|
134
|
+
assignments: Assignment[],
|
|
135
|
+
groups: ExclusionGroupConfig[] | undefined,
|
|
136
|
+
ctx: {
|
|
137
|
+
userId: string;
|
|
138
|
+
forcedExperimentIds?: Iterable<string>;
|
|
139
|
+
store?: GroupStickyStore;
|
|
140
|
+
persist?: boolean;
|
|
141
|
+
},
|
|
142
|
+
): ResolveExclusionGroupsResult {
|
|
143
|
+
const decisions: ExclusionGroupDecision[] = [];
|
|
144
|
+
if (!groups || groups.length === 0) {
|
|
145
|
+
return { assignments, decisions };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const persist = ctx.persist !== false;
|
|
149
|
+
const forced = new Set(ctx.forcedExperimentIds ?? []);
|
|
150
|
+
// Persistence-disabled contexts skip sticky reads and writes below, so they
|
|
151
|
+
// cannot inherit a prior browser or injected winner.
|
|
152
|
+
const store = ctx.store ?? browserGroupStickyStore;
|
|
153
|
+
|
|
154
|
+
// experimentId -> its pass-one assignment (only eligible experiments appear).
|
|
155
|
+
const assignedById = new Map<string, Assignment>();
|
|
156
|
+
for (const assignment of assignments) {
|
|
157
|
+
assignedById.set(assignment.experimentId, assignment);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const suppressed = new Set<string>();
|
|
161
|
+
|
|
162
|
+
for (const group of groups) {
|
|
163
|
+
if (
|
|
164
|
+
!group?.id ||
|
|
165
|
+
!Array.isArray(group.members) ||
|
|
166
|
+
group.members.length === 0
|
|
167
|
+
) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const epoch = normalizeEpoch(group.epoch);
|
|
171
|
+
const hasForcedAssignment = group.members.some(
|
|
172
|
+
(member) =>
|
|
173
|
+
assignedById.has(member.experimentId) &&
|
|
174
|
+
forced.has(member.experimentId),
|
|
175
|
+
);
|
|
176
|
+
const persistGroupSelection = persist && !hasForcedAssignment;
|
|
177
|
+
|
|
178
|
+
// Conflicting = eligible (assigned this pass) AND not forced for QA preview.
|
|
179
|
+
const conflicting = group.members.filter(
|
|
180
|
+
(member) =>
|
|
181
|
+
assignedById.has(member.experimentId) &&
|
|
182
|
+
!forced.has(member.experimentId),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (conflicting.length <= 1) {
|
|
186
|
+
// Zero or one eligible member → no exclusion decision. Remember a lone
|
|
187
|
+
// positive-share member so it stays sticky if another member overlaps
|
|
188
|
+
// later. A 0% member must never gain priority merely by appearing alone.
|
|
189
|
+
if (
|
|
190
|
+
conflicting.length === 1 &&
|
|
191
|
+
conflicting[0].share > 0 &&
|
|
192
|
+
persistGroupSelection
|
|
193
|
+
) {
|
|
194
|
+
store.write(group.id, {
|
|
195
|
+
experimentId: conflicting[0].experimentId,
|
|
196
|
+
epoch,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const conflictingIds = conflicting.map((member) => member.experimentId);
|
|
203
|
+
|
|
204
|
+
// Sticky: keep the prior winner while it stays eligible at the current epoch.
|
|
205
|
+
const prior = persistGroupSelection ? store.read(group.id) : null;
|
|
206
|
+
let winnerId: string;
|
|
207
|
+
let sticky = false;
|
|
208
|
+
const priorMember = prior
|
|
209
|
+
? conflicting.find((member) => member.experimentId === prior.experimentId)
|
|
210
|
+
: undefined;
|
|
211
|
+
const hasPositiveWeight = conflicting.some((member) => member.share > 0);
|
|
212
|
+
if (
|
|
213
|
+
prior &&
|
|
214
|
+
prior.epoch === epoch &&
|
|
215
|
+
priorMember &&
|
|
216
|
+
(priorMember.share > 0 || !hasPositiveWeight)
|
|
217
|
+
) {
|
|
218
|
+
winnerId = prior.experimentId;
|
|
219
|
+
sticky = true;
|
|
220
|
+
} else {
|
|
221
|
+
// Deterministic pick across only the conflicting members, weighted by share.
|
|
222
|
+
const configuredWeights = conflicting.map((member) =>
|
|
223
|
+
Number.isFinite(member.share) && member.share > 0 ? member.share : 0,
|
|
224
|
+
);
|
|
225
|
+
// A degenerate all-zero conflicting subset can happen after eligibility
|
|
226
|
+
// filtering. Keep the decision fair and deterministic instead of letting
|
|
227
|
+
// the generic bucket helper silently favor the first member.
|
|
228
|
+
const weights = configuredWeights.some((weight) => weight > 0)
|
|
229
|
+
? configuredWeights
|
|
230
|
+
: configuredWeights.map(() => 1);
|
|
231
|
+
const hashValue = hashToFloat(`${group.id}:exclusion`, ctx.userId);
|
|
232
|
+
const index = chooseVariation(hashValue, weights);
|
|
233
|
+
winnerId =
|
|
234
|
+
conflicting[Math.max(0, Math.min(index, conflicting.length - 1))]
|
|
235
|
+
.experimentId;
|
|
236
|
+
if (persistGroupSelection) {
|
|
237
|
+
store.write(group.id, { experimentId: winnerId, epoch });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const suppressedIds = conflictingIds.filter((id) => id !== winnerId);
|
|
242
|
+
for (const id of suppressedIds) suppressed.add(id);
|
|
243
|
+
|
|
244
|
+
decisions.push({
|
|
245
|
+
groupId: group.id,
|
|
246
|
+
conflictingExperimentIds: conflictingIds,
|
|
247
|
+
winnerExperimentId: winnerId,
|
|
248
|
+
suppressedExperimentIds: suppressedIds,
|
|
249
|
+
sticky,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (suppressed.size === 0) {
|
|
254
|
+
return { assignments, decisions };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
assignments: assignments.filter(
|
|
259
|
+
(assignment) => !suppressed.has(assignment.experimentId),
|
|
260
|
+
),
|
|
261
|
+
decisions,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const __exclusionGroupInternals = {
|
|
266
|
+
STORAGE_KEY,
|
|
267
|
+
COOKIE_NAME,
|
|
268
|
+
parseStore,
|
|
269
|
+
normalizeEpoch,
|
|
270
|
+
};
|