@drip-apex/sdk 0.1.0 → 0.3.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/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
package/src/config-fetch.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isHoldoutVisitor } from "./hash";
|
|
2
3
|
|
|
3
4
|
function makeStorage() {
|
|
4
5
|
const map = new Map<string, string>();
|
|
@@ -84,6 +85,7 @@ describe("async config fetch contract", () => {
|
|
|
84
85
|
const originalLocalStorage = (globalThis as any).localStorage;
|
|
85
86
|
const originalSessionStorage = (globalThis as any).sessionStorage;
|
|
86
87
|
const originalCustomEvent = (globalThis as any).CustomEvent;
|
|
88
|
+
const originalMutationObserver = (globalThis as any).MutationObserver;
|
|
87
89
|
const originalFetch = globalThis.fetch;
|
|
88
90
|
const originalAbortController = (globalThis as any).AbortController;
|
|
89
91
|
|
|
@@ -93,7 +95,12 @@ describe("async config fetch contract", () => {
|
|
|
93
95
|
vi.setSystemTime(0);
|
|
94
96
|
delete (globalThis as any).__drip;
|
|
95
97
|
|
|
96
|
-
const appended: Array<{
|
|
98
|
+
const appended: Array<{
|
|
99
|
+
className?: string;
|
|
100
|
+
id?: string;
|
|
101
|
+
remove?: () => void;
|
|
102
|
+
textContent?: string;
|
|
103
|
+
}> = [];
|
|
97
104
|
const windowStub: Record<string, any> = {
|
|
98
105
|
addEventListener: vi.fn(),
|
|
99
106
|
clearInterval: (id: number) => clearInterval(id),
|
|
@@ -122,12 +129,25 @@ describe("async config fetch contract", () => {
|
|
|
122
129
|
(globalThis as any).document = {
|
|
123
130
|
addEventListener: vi.fn(),
|
|
124
131
|
body: { scrollHeight: 1200 },
|
|
125
|
-
createElement: vi.fn((tag: string) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
createElement: vi.fn((tag: string) => {
|
|
133
|
+
const element: {
|
|
134
|
+
className: string;
|
|
135
|
+
id: string;
|
|
136
|
+
remove: () => void;
|
|
137
|
+
tagName: string;
|
|
138
|
+
textContent: string;
|
|
139
|
+
} = {
|
|
140
|
+
className: "",
|
|
141
|
+
id: "",
|
|
142
|
+
remove: vi.fn(() => {
|
|
143
|
+
const index = appended.indexOf(element);
|
|
144
|
+
if (index >= 0) appended.splice(index, 1);
|
|
145
|
+
}),
|
|
146
|
+
tagName: tag.toUpperCase(),
|
|
147
|
+
textContent: "",
|
|
148
|
+
};
|
|
149
|
+
return element;
|
|
150
|
+
}),
|
|
131
151
|
dispatchEvent: vi.fn(),
|
|
132
152
|
documentElement: {
|
|
133
153
|
classList: makeClassList(),
|
|
@@ -141,7 +161,13 @@ describe("async config fetch contract", () => {
|
|
|
141
161
|
}),
|
|
142
162
|
},
|
|
143
163
|
querySelector: vi.fn(() => null),
|
|
144
|
-
querySelectorAll: vi.fn(() =>
|
|
164
|
+
querySelectorAll: vi.fn((selector: string) => {
|
|
165
|
+
if (!selector.startsWith(".")) return [];
|
|
166
|
+
const className = selector.slice(1);
|
|
167
|
+
return appended.filter((element) =>
|
|
168
|
+
String(element.className ?? "").split(/\s+/).includes(className),
|
|
169
|
+
);
|
|
170
|
+
}),
|
|
145
171
|
referrer: "",
|
|
146
172
|
removeEventListener: vi.fn(),
|
|
147
173
|
title: "Plain probe",
|
|
@@ -160,6 +186,11 @@ describe("async config fetch contract", () => {
|
|
|
160
186
|
this.detail = init?.detail;
|
|
161
187
|
}
|
|
162
188
|
};
|
|
189
|
+
(globalThis as any).MutationObserver = class MutationObserver {
|
|
190
|
+
observe() {}
|
|
191
|
+
disconnect() {}
|
|
192
|
+
constructor(_callback: MutationCallback) {}
|
|
193
|
+
};
|
|
163
194
|
globalThis.fetch = vi.fn(async () => new Response("ok", { status: 202 })) as any;
|
|
164
195
|
});
|
|
165
196
|
|
|
@@ -179,8 +210,10 @@ describe("async config fetch contract", () => {
|
|
|
179
210
|
(globalThis as any).localStorage = originalLocalStorage;
|
|
180
211
|
(globalThis as any).sessionStorage = originalSessionStorage;
|
|
181
212
|
(globalThis as any).CustomEvent = originalCustomEvent;
|
|
213
|
+
(globalThis as any).MutationObserver = originalMutationObserver;
|
|
182
214
|
(globalThis as any).AbortController = originalAbortController;
|
|
183
215
|
globalThis.fetch = originalFetch;
|
|
216
|
+
vi.unstubAllGlobals();
|
|
184
217
|
});
|
|
185
218
|
|
|
186
219
|
it("bounds fetchWithTimeout when AbortController is unavailable", async () => {
|
|
@@ -220,7 +253,69 @@ describe("async config fetch contract", () => {
|
|
|
220
253
|
expect(aborts).toEqual([700]);
|
|
221
254
|
});
|
|
222
255
|
|
|
256
|
+
it("keeps the intended fetch budget when a late boot cannot install a barrier", async () => {
|
|
257
|
+
vi.stubGlobal("performance", {
|
|
258
|
+
now: () => 500,
|
|
259
|
+
getEntriesByType: () => [{ startTime: 0, domContentLoadedEventEnd: 100 }],
|
|
260
|
+
});
|
|
261
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
262
|
+
const aborts: number[] = [];
|
|
263
|
+
globalThis.fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) =>
|
|
264
|
+
new Promise<Response>((_resolve, reject) => {
|
|
265
|
+
init?.signal?.addEventListener("abort", () => {
|
|
266
|
+
aborts.push(Date.now());
|
|
267
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
268
|
+
});
|
|
269
|
+
})) as any;
|
|
270
|
+
const { init } = await import("./index");
|
|
271
|
+
|
|
272
|
+
init({
|
|
273
|
+
antiFlickerMode: "full",
|
|
274
|
+
antiFlickerTimeoutMs: 700,
|
|
275
|
+
debug: { enabled: true, restrictMode: "always" },
|
|
276
|
+
endpoint: "https://events.test",
|
|
277
|
+
shopId: "shop_123",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(document.getElementById("drip-antiflicker")).toBeNull();
|
|
281
|
+
expect(logSpy.mock.calls.some(([message]) =>
|
|
282
|
+
String(message).includes("loaded after the pre-paint window"),
|
|
283
|
+
)).toBe(true);
|
|
284
|
+
await vi.advanceTimersByTimeAsync(700);
|
|
285
|
+
expect(aborts).toEqual([700]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("latches an early boot so the post-fetch re-arm still installs a barrier", async () => {
|
|
289
|
+
vi.stubGlobal("performance", {
|
|
290
|
+
now: () => Date.now(),
|
|
291
|
+
getEntriesByType: () => [{ startTime: 0, domContentLoadedEventEnd: 100 }],
|
|
292
|
+
});
|
|
293
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
294
|
+
if (!String(input).includes("/config")) {
|
|
295
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
296
|
+
}
|
|
297
|
+
return new Promise<Response>((resolve) => {
|
|
298
|
+
setTimeout(() => resolve(new Response(JSON.stringify(configPayload()), { status: 200 })), 500);
|
|
299
|
+
});
|
|
300
|
+
}) as any;
|
|
301
|
+
const { init } = await import("./index");
|
|
302
|
+
|
|
303
|
+
init({
|
|
304
|
+
antiFlickerMode: "full",
|
|
305
|
+
antiFlickerTimeoutMs: 1_000,
|
|
306
|
+
endpoint: "https://events.test",
|
|
307
|
+
shopId: "shop_123",
|
|
308
|
+
});
|
|
309
|
+
expect(document.head.appendChild).toHaveBeenCalledTimes(1);
|
|
310
|
+
|
|
311
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
312
|
+
await flushPromises(10);
|
|
313
|
+
|
|
314
|
+
expect(document.head.appendChild).toHaveBeenCalledTimes(2);
|
|
315
|
+
});
|
|
316
|
+
|
|
223
317
|
it("skips applying late config by default but writes the config cache", async () => {
|
|
318
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
224
319
|
let configCalls = 0;
|
|
225
320
|
globalThis.fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
|
|
226
321
|
if (!String(input).includes("/config")) {
|
|
@@ -243,8 +338,9 @@ describe("async config fetch contract", () => {
|
|
|
243
338
|
const { getDiagnostics, init } = await import("./index");
|
|
244
339
|
|
|
245
340
|
init({
|
|
246
|
-
antiFlickerMode: "
|
|
341
|
+
antiFlickerMode: "scoped",
|
|
247
342
|
antiFlickerTimeoutMs: 300,
|
|
343
|
+
debug: { enabled: true, restrictMode: "always" },
|
|
248
344
|
endpoint: "https://events.test",
|
|
249
345
|
shopId: "shop_123",
|
|
250
346
|
});
|
|
@@ -258,6 +354,9 @@ describe("async config fetch contract", () => {
|
|
|
258
354
|
expect(getDiagnostics().assignments).toHaveLength(0);
|
|
259
355
|
expect(getDiagnostics().initialized).toBe(false);
|
|
260
356
|
expect(getDiagnostics().load.stage).toBe("late_config_skip");
|
|
357
|
+
expect(logSpy.mock.calls.some(([message]) =>
|
|
358
|
+
String(message).includes("loaded after the pre-paint window"),
|
|
359
|
+
)).toBe(false);
|
|
261
360
|
expect(document.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({
|
|
262
361
|
type: "drip_runtime_status",
|
|
263
362
|
detail: expect.objectContaining({ stage: "late_config_skip", configFetchMs: 850 }),
|
|
@@ -322,4 +421,376 @@ describe("async config fetch contract", () => {
|
|
|
322
421
|
expect(getDiagnostics().configSource).toBe("network");
|
|
323
422
|
expect(getDiagnostics().load.stage).toBe("assignments_ready");
|
|
324
423
|
});
|
|
424
|
+
|
|
425
|
+
it("applies holdout gating and cohort tagging from a fetched config", async () => {
|
|
426
|
+
const holdoutConfigUpdatedAt = "2026-07-27T08:30:00.000Z";
|
|
427
|
+
const shopifyPublish = vi.fn();
|
|
428
|
+
(globalThis as any).window.Shopify = {
|
|
429
|
+
analytics: { publish: shopifyPublish },
|
|
430
|
+
};
|
|
431
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
432
|
+
if (String(input).includes("/config")) {
|
|
433
|
+
return Promise.resolve(new Response(JSON.stringify({
|
|
434
|
+
...configPayload(),
|
|
435
|
+
holdout_v1_enabled: true,
|
|
436
|
+
holdout_pct: 10,
|
|
437
|
+
holdout_config_updated_at: holdoutConfigUpdatedAt,
|
|
438
|
+
}), { status: 200 }));
|
|
439
|
+
}
|
|
440
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
441
|
+
}) as any;
|
|
442
|
+
const { flush, getDiagnostics, init, track } = await import("./index");
|
|
443
|
+
let visitorId = "holdout-config-0";
|
|
444
|
+
while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
|
|
445
|
+
visitorId = `holdout-config-${Number(visitorId.split("-").at(-1)) + 1}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
init({ antiFlickerMode: "off", endpoint: "https://events.test", shopId: "shop_123", trackingStart: "immediate", userId: visitorId });
|
|
449
|
+
await waitFor(() => getDiagnostics().configSource === "network");
|
|
450
|
+
track("holdout_probe");
|
|
451
|
+
await flush();
|
|
452
|
+
|
|
453
|
+
expect(getDiagnostics().assignments).toHaveLength(0);
|
|
454
|
+
const eventsRequest = vi.mocked(globalThis.fetch).mock.calls.find(
|
|
455
|
+
(call) => String(call[0]).endsWith("/events"),
|
|
456
|
+
);
|
|
457
|
+
const events = JSON.parse(String(eventsRequest?.[1]?.body)).events;
|
|
458
|
+
expect(events.find((event: { type: string }) => event.type === "holdout_probe").data.cohort).toBe("holdout");
|
|
459
|
+
const cached = JSON.parse(String(localStorage.getItem("drip_cfg:shop_123:production")));
|
|
460
|
+
expect(cached.holdout).toEqual([true, 10, holdoutConfigUpdatedAt]);
|
|
461
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
462
|
+
"drip_assignment_sync",
|
|
463
|
+
expect.objectContaining({
|
|
464
|
+
cohort: "holdout",
|
|
465
|
+
holdoutConfigEpoch: Date.parse(holdoutConfigUpdatedAt),
|
|
466
|
+
}),
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("restores the holdout configuration epoch from the config cache", async () => {
|
|
471
|
+
const holdoutConfigUpdatedAt = "2026-07-27T08:30:00.000Z";
|
|
472
|
+
const shopifyPublish = vi.fn();
|
|
473
|
+
(globalThis as any).window.Shopify = {
|
|
474
|
+
analytics: { publish: shopifyPublish },
|
|
475
|
+
};
|
|
476
|
+
let visitorId = "cached-epoch-holdout-0";
|
|
477
|
+
while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
|
|
478
|
+
visitorId = `cached-epoch-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
|
|
479
|
+
}
|
|
480
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
481
|
+
fetchedAt: Date.now(),
|
|
482
|
+
hash: "cached-holdout-epoch",
|
|
483
|
+
experiments: [],
|
|
484
|
+
holdout: [true, 10, holdoutConfigUpdatedAt],
|
|
485
|
+
}));
|
|
486
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
487
|
+
if (String(input).includes("/config")) {
|
|
488
|
+
return new Promise<Response>(() => {});
|
|
489
|
+
}
|
|
490
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
491
|
+
}) as any;
|
|
492
|
+
const { flush, getDiagnostics, init, track } = await import("./index");
|
|
493
|
+
|
|
494
|
+
init({
|
|
495
|
+
antiFlickerMode: "off",
|
|
496
|
+
endpoint: "https://events.test",
|
|
497
|
+
shopId: "shop_123",
|
|
498
|
+
trackingStart: "immediate",
|
|
499
|
+
userId: visitorId,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(getDiagnostics().configSource).toBe("cache");
|
|
503
|
+
track("cached_holdout_epoch_probe");
|
|
504
|
+
await flush();
|
|
505
|
+
const eventsRequest = vi.mocked(globalThis.fetch).mock.calls.find(
|
|
506
|
+
(call) => String(call[0]).endsWith("/events"),
|
|
507
|
+
);
|
|
508
|
+
const events = JSON.parse(String(eventsRequest?.[1]?.body)).events;
|
|
509
|
+
expect(events.find((event: { type: string }) =>
|
|
510
|
+
event.type === "cached_holdout_epoch_probe"
|
|
511
|
+
).data).toMatchObject({
|
|
512
|
+
cohort: "holdout",
|
|
513
|
+
holdout_config_epoch: Date.parse(holdoutConfigUpdatedAt),
|
|
514
|
+
});
|
|
515
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
516
|
+
"drip_assignment_sync",
|
|
517
|
+
expect.objectContaining({
|
|
518
|
+
cohort: "holdout",
|
|
519
|
+
holdoutConfigEpoch: Date.parse(holdoutConfigUpdatedAt),
|
|
520
|
+
}),
|
|
521
|
+
);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("hydrates cached geography before evaluating country targeting", async () => {
|
|
525
|
+
const countryExperiment = {
|
|
526
|
+
id: "exp_cached_country",
|
|
527
|
+
status: "running",
|
|
528
|
+
trafficAllocation: 1,
|
|
529
|
+
targeting: {
|
|
530
|
+
segmentRules: [{
|
|
531
|
+
include: true,
|
|
532
|
+
rules: {
|
|
533
|
+
conditions: [{ signal: "geo.country", operator: "equals", value: "DE" }],
|
|
534
|
+
},
|
|
535
|
+
}],
|
|
536
|
+
},
|
|
537
|
+
variations: [{ id: "var_cached_country", weight: 1, mutations: [] }],
|
|
538
|
+
};
|
|
539
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
540
|
+
fetchedAt: Date.now(),
|
|
541
|
+
hash: "cached-country-config",
|
|
542
|
+
experiments: [countryExperiment],
|
|
543
|
+
geo: { country: "DE" },
|
|
544
|
+
}));
|
|
545
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
546
|
+
if (String(input).includes("/config")) return new Promise<Response>(() => {});
|
|
547
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
548
|
+
}) as any;
|
|
549
|
+
const { getDiagnostics, init } = await import("./index");
|
|
550
|
+
|
|
551
|
+
init({
|
|
552
|
+
antiFlickerMode: "off",
|
|
553
|
+
endpoint: "https://events.test",
|
|
554
|
+
shopId: "shop_123",
|
|
555
|
+
userId: "cached-country-visitor",
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(getDiagnostics().configSource).toBe("cache");
|
|
559
|
+
expect(getDiagnostics().assignments).toEqual([
|
|
560
|
+
expect.objectContaining({ experimentId: "exp_cached_country" }),
|
|
561
|
+
]);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("keeps fresh request geography when cached geography differs", async () => {
|
|
565
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
566
|
+
fetchedAt: Date.now(),
|
|
567
|
+
hash: "cached-country-config",
|
|
568
|
+
experiments: [{
|
|
569
|
+
id: "exp_fresh_country",
|
|
570
|
+
status: "running",
|
|
571
|
+
trafficAllocation: 1,
|
|
572
|
+
targeting: {
|
|
573
|
+
segmentRules: [{
|
|
574
|
+
include: true,
|
|
575
|
+
rules: {
|
|
576
|
+
conditions: [{ signal: "geo.country", operator: "equals", value: "US" }],
|
|
577
|
+
},
|
|
578
|
+
}],
|
|
579
|
+
},
|
|
580
|
+
variations: [{ id: "var_fresh_country", weight: 1, mutations: [] }],
|
|
581
|
+
}],
|
|
582
|
+
geo: { country: "DE" },
|
|
583
|
+
}));
|
|
584
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
585
|
+
if (String(input).includes("/config")) return new Promise<Response>(() => {});
|
|
586
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
587
|
+
}) as any;
|
|
588
|
+
const { getDiagnostics, getSignals, init } = await import("./index");
|
|
589
|
+
|
|
590
|
+
init({
|
|
591
|
+
antiFlickerMode: "off",
|
|
592
|
+
endpoint: "https://events.test",
|
|
593
|
+
geo: { country: "US" },
|
|
594
|
+
shopId: "shop_123",
|
|
595
|
+
userId: "fresh-country-visitor",
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
expect(getDiagnostics().configSource).toBe("cache");
|
|
599
|
+
expect(getSignals({ markVisitorSeen: false })["geo.country"]).toBe("US");
|
|
600
|
+
expect(getDiagnostics().assignments).toEqual([
|
|
601
|
+
expect.objectContaining({ experimentId: "exp_fresh_country" }),
|
|
602
|
+
]);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("keeps legacy cached envelopes without geography compatible", async () => {
|
|
606
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
607
|
+
fetchedAt: Date.now(),
|
|
608
|
+
hash: "legacy-cached-country-config",
|
|
609
|
+
experiments: [{
|
|
610
|
+
id: "exp_legacy_cached_country",
|
|
611
|
+
status: "running",
|
|
612
|
+
trafficAllocation: 1,
|
|
613
|
+
targeting: {
|
|
614
|
+
segmentRules: [{
|
|
615
|
+
include: true,
|
|
616
|
+
rules: {
|
|
617
|
+
conditions: [{ signal: "geo.country", operator: "equals", value: "DE" }],
|
|
618
|
+
},
|
|
619
|
+
}],
|
|
620
|
+
},
|
|
621
|
+
variations: [{ id: "var_legacy_cached_country", weight: 1, mutations: [] }],
|
|
622
|
+
}],
|
|
623
|
+
}));
|
|
624
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
625
|
+
if (String(input).includes("/config")) return new Promise<Response>(() => {});
|
|
626
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
627
|
+
}) as any;
|
|
628
|
+
const { getDiagnostics, init } = await import("./index");
|
|
629
|
+
|
|
630
|
+
expect(() => init({
|
|
631
|
+
antiFlickerMode: "off",
|
|
632
|
+
endpoint: "https://events.test",
|
|
633
|
+
shopId: "shop_123",
|
|
634
|
+
userId: "legacy-cached-country-visitor",
|
|
635
|
+
})).not.toThrow();
|
|
636
|
+
expect(getDiagnostics().configSource).toBe("cache");
|
|
637
|
+
expect(getDiagnostics().assignments).toHaveLength(0);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("synchronously gates a holdout visitor before applying cached treatments or project code", async () => {
|
|
641
|
+
const target = { textContent: "Control" };
|
|
642
|
+
const querySelectorAll = document.querySelectorAll.bind(document);
|
|
643
|
+
document.querySelectorAll = vi.fn((selector: string) => (
|
|
644
|
+
selector === "#cached-title"
|
|
645
|
+
? [target]
|
|
646
|
+
: querySelectorAll(selector)
|
|
647
|
+
)) as typeof document.querySelectorAll;
|
|
648
|
+
|
|
649
|
+
let visitorId = "cached-holdout-0";
|
|
650
|
+
while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
|
|
651
|
+
visitorId = `cached-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
|
|
652
|
+
}
|
|
653
|
+
const cachedExperiments = [{
|
|
654
|
+
id: "exp_cached_holdout",
|
|
655
|
+
status: "running",
|
|
656
|
+
trafficAllocation: 1,
|
|
657
|
+
js: "window.__cachedHoldoutExperiment = true;",
|
|
658
|
+
variations: [{
|
|
659
|
+
id: "var_cached_holdout",
|
|
660
|
+
weight: 1,
|
|
661
|
+
mutations: [{
|
|
662
|
+
selector: "#cached-title",
|
|
663
|
+
action: "text",
|
|
664
|
+
text: "Cached treatment",
|
|
665
|
+
}],
|
|
666
|
+
css: "#cached-title { color: red; }",
|
|
667
|
+
js: "window.__cachedHoldoutTreatment = true;",
|
|
668
|
+
}],
|
|
669
|
+
}];
|
|
670
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
671
|
+
fetchedAt: Date.now(),
|
|
672
|
+
hash: "cached-holdout-config",
|
|
673
|
+
experiments: cachedExperiments,
|
|
674
|
+
runtime: {
|
|
675
|
+
projectCode: {
|
|
676
|
+
enabled: true,
|
|
677
|
+
css: "body { outline: 1px solid red; }",
|
|
678
|
+
helperJs: "window.__cachedHoldoutProjectHelper = true;",
|
|
679
|
+
js: "window.__cachedHoldoutProjectStartup = true;",
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
holdout: [true, 10],
|
|
683
|
+
}));
|
|
684
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
685
|
+
if (String(input).includes("/config")) {
|
|
686
|
+
return new Promise<Response>(() => {});
|
|
687
|
+
}
|
|
688
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
689
|
+
}) as any;
|
|
690
|
+
const { getDiagnostics, init } = await import("./index");
|
|
691
|
+
|
|
692
|
+
const assignments = init({
|
|
693
|
+
antiFlickerMode: "off",
|
|
694
|
+
endpoint: "https://events.test",
|
|
695
|
+
shopId: "shop_123",
|
|
696
|
+
userId: visitorId,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(assignments).toHaveLength(0);
|
|
700
|
+
expect(getDiagnostics().configSource).toBe("cache");
|
|
701
|
+
expect(target.textContent).toBe("Control");
|
|
702
|
+
expect((window as any).__cachedHoldoutTreatment).toBeUndefined();
|
|
703
|
+
expect((window as any).__cachedHoldoutExperiment).toBeUndefined();
|
|
704
|
+
expect((window as any).__cachedHoldoutProjectHelper).toBeUndefined();
|
|
705
|
+
expect((window as any).__cachedHoldoutProjectStartup).toBeUndefined();
|
|
706
|
+
expect(document.getElementById("drip-css-exp_cached_holdout-var_cached")).toBeNull();
|
|
707
|
+
expect(document.getElementById("drip-project-css")).toBeNull();
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("tears down cached treatments when the network config moves the visitor into holdout", async () => {
|
|
711
|
+
const target = { textContent: "Control" };
|
|
712
|
+
const querySelectorAll = document.querySelectorAll.bind(document);
|
|
713
|
+
document.querySelectorAll = vi.fn((selector: string) => (
|
|
714
|
+
selector === "#cached-title"
|
|
715
|
+
? [target]
|
|
716
|
+
: querySelectorAll(selector)
|
|
717
|
+
)) as typeof document.querySelectorAll;
|
|
718
|
+
|
|
719
|
+
let visitorId = "cached-network-holdout-0";
|
|
720
|
+
while (!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, visitorId)) {
|
|
721
|
+
visitorId = `cached-network-holdout-${Number(visitorId.split("-").at(-1)) + 1}`;
|
|
722
|
+
}
|
|
723
|
+
const cachedExperiments = [{
|
|
724
|
+
id: "exp_cached",
|
|
725
|
+
status: "running",
|
|
726
|
+
trafficAllocation: 1,
|
|
727
|
+
js: "window.__cachedExperiment = true;",
|
|
728
|
+
resetJs: "window.__cachedExperiment = false;",
|
|
729
|
+
variations: [{
|
|
730
|
+
id: "var_cached",
|
|
731
|
+
weight: 1,
|
|
732
|
+
mutations: [{
|
|
733
|
+
selector: "#cached-title",
|
|
734
|
+
action: "text",
|
|
735
|
+
text: "Cached treatment",
|
|
736
|
+
}],
|
|
737
|
+
css: "#cached-title { color: red; }",
|
|
738
|
+
js: "window.__cachedTreatment = true;",
|
|
739
|
+
resetJs: "window.__cachedTreatment = false;",
|
|
740
|
+
}],
|
|
741
|
+
}];
|
|
742
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
743
|
+
fetchedAt: Date.now(),
|
|
744
|
+
hash: "cached-active-config",
|
|
745
|
+
experiments: cachedExperiments,
|
|
746
|
+
runtime: {
|
|
747
|
+
projectCode: {
|
|
748
|
+
enabled: true,
|
|
749
|
+
css: "body { outline: 1px solid red; }",
|
|
750
|
+
helperJs: "window.__cachedProjectHelper = true;",
|
|
751
|
+
js: "window.__cachedProjectStartup = true;",
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
holdout: [false, 0],
|
|
755
|
+
}));
|
|
756
|
+
let resolveConfig!: (response: Response) => void;
|
|
757
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
758
|
+
if (String(input).includes("/config")) {
|
|
759
|
+
return new Promise<Response>((resolve) => {
|
|
760
|
+
resolveConfig = resolve;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
764
|
+
}) as any;
|
|
765
|
+
const { getDiagnostics, init } = await import("./index");
|
|
766
|
+
|
|
767
|
+
init({
|
|
768
|
+
antiFlickerMode: "off",
|
|
769
|
+
endpoint: "https://events.test",
|
|
770
|
+
lateConfigPolicy: "apply",
|
|
771
|
+
shopId: "shop_123",
|
|
772
|
+
userId: visitorId,
|
|
773
|
+
});
|
|
774
|
+
await flushPromises();
|
|
775
|
+
|
|
776
|
+
expect(target.textContent).toBe("Cached treatment");
|
|
777
|
+
expect((window as any).__cachedTreatment).toBe(true);
|
|
778
|
+
expect((window as any).__cachedExperiment).toBe(true);
|
|
779
|
+
expect(document.getElementById("drip-css-exp_cached-var_cached")).not.toBeNull();
|
|
780
|
+
expect(document.getElementById("drip-project-css")).not.toBeNull();
|
|
781
|
+
|
|
782
|
+
resolveConfig(new Response(JSON.stringify({
|
|
783
|
+
experiments: cachedExperiments,
|
|
784
|
+
holdout_v1_enabled: true,
|
|
785
|
+
holdout_pct: 10,
|
|
786
|
+
}), { status: 200 }));
|
|
787
|
+
await waitFor(() => getDiagnostics().configSource === "network");
|
|
788
|
+
|
|
789
|
+
expect(getDiagnostics().assignments).toHaveLength(0);
|
|
790
|
+
expect(target.textContent).toBe("Control");
|
|
791
|
+
expect((window as any).__cachedTreatment).toBe(false);
|
|
792
|
+
expect((window as any).__cachedExperiment).toBe(false);
|
|
793
|
+
expect(document.getElementById("drip-css-exp_cached-var_cached")).toBeNull();
|
|
794
|
+
expect(document.getElementById("drip-project-css")).toBeNull();
|
|
795
|
+
});
|
|
325
796
|
});
|