@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
package/src/guards.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
const BOT_RE = /bot|google|crawler|spider|robot|crawling|facebookexternalhit|bingpreview|slurp/i;
|
|
2
|
+
|
|
3
|
+
export function isBot(): boolean {
|
|
4
|
+
return BOT_RE.test(navigator.userAgent);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function isIframe(): boolean {
|
|
8
|
+
try {
|
|
9
|
+
return window.self !== window.top;
|
|
10
|
+
} catch {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isDuplicate(): boolean {
|
|
16
|
+
if ((globalThis as Record<string, unknown>).__drip) return true;
|
|
17
|
+
(globalThis as Record<string, unknown>).__drip = true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isOptedOut(options?: { storageAllowed?: boolean }): boolean {
|
|
22
|
+
const storageAllowed = options?.storageAllowed !== false;
|
|
23
|
+
const param = new URLSearchParams(location.search).get("drip_optout");
|
|
24
|
+
if (param === "true") {
|
|
25
|
+
if (storageAllowed) {
|
|
26
|
+
try {
|
|
27
|
+
localStorage.setItem("drip_optout", "1");
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
if (param === "false") {
|
|
33
|
+
if (storageAllowed) {
|
|
34
|
+
try {
|
|
35
|
+
localStorage.removeItem("drip_optout");
|
|
36
|
+
} catch {}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (!storageAllowed) return false;
|
|
41
|
+
try {
|
|
42
|
+
return localStorage.getItem("drip_optout") === "1";
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isScreenshotMode(): boolean {
|
|
49
|
+
const param = new URLSearchParams(location.search).get("drip_screenshot");
|
|
50
|
+
return param === "1" || param === "true";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const FORCE_EXPERIMENT_PARAM_NAMES = [
|
|
54
|
+
"drip_experiment_id",
|
|
55
|
+
"drip_experiment",
|
|
56
|
+
"experiment_id",
|
|
57
|
+
"experimentId",
|
|
58
|
+
"expId",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const FORCE_VARIATION_PARAM_NAMES = [
|
|
62
|
+
"drip_variation_id",
|
|
63
|
+
"drip_variation",
|
|
64
|
+
"variation_id",
|
|
65
|
+
"variationId",
|
|
66
|
+
"varId",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function readFirstParam(params: URLSearchParams, names: string[]): string | null {
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const value = params.get(name)?.trim();
|
|
72
|
+
if (value) return value;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseForceParams(params: URLSearchParams): Record<string, string> | null {
|
|
78
|
+
const param = params.get("drip_force");
|
|
79
|
+
if (param) {
|
|
80
|
+
const map = parseForceString(param);
|
|
81
|
+
if (map) return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const experimentId = readFirstParam(params, FORCE_EXPERIMENT_PARAM_NAMES);
|
|
85
|
+
const variationId = readFirstParam(params, FORCE_VARIATION_PARAM_NAMES);
|
|
86
|
+
if (experimentId && variationId) {
|
|
87
|
+
return { [experimentId]: variationId };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isDebugQaParam(params: URLSearchParams): boolean {
|
|
93
|
+
return (
|
|
94
|
+
params.get("drip_debug") === "1" ||
|
|
95
|
+
params.get("apex_debug") === "1" ||
|
|
96
|
+
params.get("drip_devtools") === "1"
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseForceParam(options?: { cookieFallback?: boolean }): Record<string, string> | null {
|
|
101
|
+
// Check URL param first
|
|
102
|
+
const map = parseForceParams(new URLSearchParams(location.search));
|
|
103
|
+
if (map) return map;
|
|
104
|
+
|
|
105
|
+
// Fallback to QA cookie
|
|
106
|
+
if (options?.cookieFallback !== false) {
|
|
107
|
+
const cookie = readCookie("drip_qa_force");
|
|
108
|
+
if (cookie) {
|
|
109
|
+
const map = parseForceString(cookie);
|
|
110
|
+
if (map) return map;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseForceString(value: string): Record<string, string> | null {
|
|
118
|
+
const map: Record<string, string> = {};
|
|
119
|
+
for (const pair of value.split(",")) {
|
|
120
|
+
const [expId, varId] = pair.split(":");
|
|
121
|
+
if (expId && varId) map[expId] = varId;
|
|
122
|
+
}
|
|
123
|
+
return Object.keys(map).length ? map : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// QA mode detection and cookie persistence
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
const QA_COOKIE = "drip_qa_force";
|
|
131
|
+
const QA_SESSION_STORAGE_KEY = "drip_qa_session";
|
|
132
|
+
const QA_COOKIE_MAX_AGE = 1800; // 30 minutes
|
|
133
|
+
|
|
134
|
+
export function isQaMode(options?: { cookieFallback?: boolean }): boolean {
|
|
135
|
+
if (isScreenshotMode()) return false;
|
|
136
|
+
const params = new URLSearchParams(location.search);
|
|
137
|
+
const param = params.get("drip_qa");
|
|
138
|
+
if (param === "1") return true;
|
|
139
|
+
if (isDebugQaParam(params) && parseForceParams(params)) return true;
|
|
140
|
+
if (options?.cookieFallback === false) return false;
|
|
141
|
+
return !!readCookie(QA_COOKIE);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Persist the force map in a cookie so QA sessions survive navigation.
|
|
146
|
+
* Cookie expires after 30 minutes.
|
|
147
|
+
*/
|
|
148
|
+
export function persistQaForce(forceMap: Record<string, string>): void {
|
|
149
|
+
const value = Object.entries(forceMap)
|
|
150
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
151
|
+
.join(",");
|
|
152
|
+
writeCookie(QA_COOKIE, value, QA_COOKIE_MAX_AGE);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sanitizeQaSessionId(value: string | null): string | null {
|
|
156
|
+
if (!value) return null;
|
|
157
|
+
const trimmed = value.trim();
|
|
158
|
+
return /^[a-zA-Z0-9_-]{3,120}$/.test(trimmed) ? trimmed : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function getQaSessionId(fallback: () => string, options?: { storageAllowed?: boolean }): string {
|
|
162
|
+
const storageAllowed = options?.storageAllowed !== false;
|
|
163
|
+
const param = sanitizeQaSessionId(
|
|
164
|
+
new URLSearchParams(location.search).get("drip_qa_session"),
|
|
165
|
+
);
|
|
166
|
+
if (param) {
|
|
167
|
+
if (storageAllowed) {
|
|
168
|
+
try {
|
|
169
|
+
sessionStorage.setItem(QA_SESSION_STORAGE_KEY, param);
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
return param;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!storageAllowed) return fallback();
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const stored = sanitizeQaSessionId(sessionStorage.getItem(QA_SESSION_STORAGE_KEY));
|
|
179
|
+
if (stored) return stored;
|
|
180
|
+
const generated = fallback();
|
|
181
|
+
sessionStorage.setItem(QA_SESSION_STORAGE_KEY, generated);
|
|
182
|
+
return generated;
|
|
183
|
+
} catch {
|
|
184
|
+
return fallback();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Exit QA mode: clear the force cookie and remove drip_qa/drip_force from URL.
|
|
190
|
+
*/
|
|
191
|
+
export function exitQaMode(): void {
|
|
192
|
+
deleteCookie(QA_COOKIE);
|
|
193
|
+
try {
|
|
194
|
+
sessionStorage.removeItem(QA_SESSION_STORAGE_KEY);
|
|
195
|
+
} catch {}
|
|
196
|
+
// Remove QA params from URL without reload
|
|
197
|
+
try {
|
|
198
|
+
const url = new URL(location.href);
|
|
199
|
+
url.searchParams.delete("drip_qa");
|
|
200
|
+
url.searchParams.delete("drip_force");
|
|
201
|
+
url.searchParams.delete("drip_experiment_id");
|
|
202
|
+
url.searchParams.delete("drip_experiment");
|
|
203
|
+
url.searchParams.delete("experiment_id");
|
|
204
|
+
url.searchParams.delete("experimentId");
|
|
205
|
+
url.searchParams.delete("expId");
|
|
206
|
+
url.searchParams.delete("drip_variation_id");
|
|
207
|
+
url.searchParams.delete("drip_variation");
|
|
208
|
+
url.searchParams.delete("variation_id");
|
|
209
|
+
url.searchParams.delete("variationId");
|
|
210
|
+
url.searchParams.delete("varId");
|
|
211
|
+
url.searchParams.delete("drip_qa_session");
|
|
212
|
+
url.searchParams.delete("drip_harness");
|
|
213
|
+
url.searchParams.delete("drip_debug");
|
|
214
|
+
url.searchParams.delete("apex_debug");
|
|
215
|
+
history.replaceState(null, "", url.toString());
|
|
216
|
+
} catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Cookie helpers
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export function readCookie(name: string): string | null {
|
|
224
|
+
try {
|
|
225
|
+
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
|
226
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function writeCookie(name: string, value: string, maxAge: number): void {
|
|
233
|
+
try {
|
|
234
|
+
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${maxAge};samesite=lax`;
|
|
235
|
+
} catch {}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function deleteCookie(name: string): void {
|
|
239
|
+
try {
|
|
240
|
+
document.cookie = `${name}=;path=/;max-age=0;samesite=lax`;
|
|
241
|
+
} catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @internal — exposed for unit testing only. Tree-shaken from production build. */
|
|
245
|
+
export const __test = {
|
|
246
|
+
parseForceString,
|
|
247
|
+
parseForceParams,
|
|
248
|
+
sanitizeQaSessionId,
|
|
249
|
+
BOT_RE,
|
|
250
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
describe("cached configuration handover ordering", () => {
|
|
5
|
+
it("verifies through the credential-free endpoint with a bounded request", () => {
|
|
6
|
+
const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
|
|
7
|
+
const start = source.indexOf("async function verifyBucketingHandoverToken");
|
|
8
|
+
const end = source.indexOf("function clearScheduledAnalyticsStart", start);
|
|
9
|
+
const verification = source.slice(start, end);
|
|
10
|
+
|
|
11
|
+
expect(verification).toContain("/v1/handover/verify");
|
|
12
|
+
expect(verification).toContain('"X-Drip-Shop": config.shopId');
|
|
13
|
+
expect(verification).toContain("JSON.stringify({ verifyToken: token })");
|
|
14
|
+
expect(verification).toContain("signal: AbortSignal.timeout(handoverMintTimeoutMs(config.externalTracking))");
|
|
15
|
+
expect(verification).not.toContain("ingestSignature");
|
|
16
|
+
expect(verification).not.toContain("X-Drip-Signature");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("starts verification before a cached config can initialize and supplies its result", () => {
|
|
20
|
+
const source = readFileSync(new URL("./index.ts", import.meta.url), "utf8");
|
|
21
|
+
const cacheRead = source.indexOf("const cached = useConfigCache");
|
|
22
|
+
const verification = source.indexOf("const handoverPromise = readBucketingHandover", cacheRead);
|
|
23
|
+
const cachedBranch = source.indexOf("if (cached)", cacheRead);
|
|
24
|
+
const networkFetch = source.indexOf("fetchWithRetry(configUrl.toString()", cachedBranch);
|
|
25
|
+
const cachedPath = source.slice(cachedBranch, networkFetch);
|
|
26
|
+
|
|
27
|
+
expect(verification).toBeGreaterThan(cacheRead);
|
|
28
|
+
expect(verification).toBeLessThan(cachedBranch);
|
|
29
|
+
expect(cachedPath).toContain("handoverPromise.then(initCachedConfig)");
|
|
30
|
+
expect(cachedPath).toContain('"cache",\n perfContext,\n verifiedHandover');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createBucketingHandoverPayload, installBucketingHandover, readBucketingHandover } from "./handover";
|
|
3
|
+
|
|
4
|
+
describe("installBucketingHandover", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.useRealTimers();
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("accepts only a Worker-verified, unexpired token for the current shop", async () => {
|
|
11
|
+
vi.useFakeTimers();
|
|
12
|
+
vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
|
|
13
|
+
vi.stubGlobal("location", { href: "https://checkout.example.com/?apex_bucket=signed.token" });
|
|
14
|
+
const settings = { domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" };
|
|
15
|
+
const verify = vi.fn(async () => ({
|
|
16
|
+
v: 2, shop: "shop_a", exp: Math.floor(Date.now() / 1000) + 120,
|
|
17
|
+
vid: "visitor_1", asg: [{ e: "exp_1", x: "var_1" }],
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
await expect(readBucketingHandover(settings, "shop_a", verify)).resolves.toEqual({
|
|
21
|
+
visitorId: "visitor_1",
|
|
22
|
+
forceMap: { exp_1: "var_1" },
|
|
23
|
+
});
|
|
24
|
+
expect(verify).toHaveBeenCalledWith("signed.token");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects absent or invalid signatures and never decodes the raw payload", async () => {
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
|
|
30
|
+
vi.stubGlobal("location", { href: "https://checkout.example.com/?apex_bucket=crafted-unsigned-payload" });
|
|
31
|
+
await expect(readBucketingHandover(
|
|
32
|
+
{ domains: ["checkout.example.com"] }, "shop_a", async () => null,
|
|
33
|
+
)).resolves.toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects a valid token minted for a different shop", async () => {
|
|
37
|
+
vi.stubGlobal("location", { href: "https://checkout.example.com/?drip_bucket=signed.token" });
|
|
38
|
+
await expect(readBucketingHandover(
|
|
39
|
+
{ domains: ["checkout.example.com"] }, "shop_b",
|
|
40
|
+
async () => ({ shop: "shop_a", exp: Math.floor(Date.now() / 1000) + 120, vid: "attacker" }),
|
|
41
|
+
)).resolves.toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it.each([1, 2])("rejects expired v%s payloads", async (version) => {
|
|
45
|
+
vi.stubGlobal("location", { href: "https://checkout.example.com/?drip_bucket=signed.token" });
|
|
46
|
+
await expect(readBucketingHandover(
|
|
47
|
+
{ domains: ["checkout.example.com"] }, "shop_a",
|
|
48
|
+
async () => ({ v: version, shop: "shop_a", exp: Math.floor(Date.now() / 1000) - 1, vid: "expired" }),
|
|
49
|
+
)).resolves.toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not read handover params when external tracking has no domains", async () => {
|
|
53
|
+
vi.stubGlobal("location", { href: "https://checkout.example.com/?drip_bucket=signed.token" });
|
|
54
|
+
const verify = vi.fn(async () => ({ shop: "shop_a", exp: Math.floor(Date.now() / 1000) + 120 }));
|
|
55
|
+
await expect(readBucketingHandover({}, "shop_a", verify)).resolves.toBeNull();
|
|
56
|
+
expect(verify).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not append an unsigned payload when signing is unavailable", () => {
|
|
60
|
+
const listeners = new Map<string, EventListener>();
|
|
61
|
+
vi.stubGlobal("document", {
|
|
62
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
63
|
+
listeners.set(type, listener);
|
|
64
|
+
}),
|
|
65
|
+
removeEventListener: vi.fn(),
|
|
66
|
+
});
|
|
67
|
+
vi.stubGlobal("location", { href: "https://store.example.com/products/a" });
|
|
68
|
+
|
|
69
|
+
let href = "https://checkout.example.com/next-step";
|
|
70
|
+
const link = {
|
|
71
|
+
getAttribute: (name: string) => (name === "href" ? href : null),
|
|
72
|
+
set href(value: string) {
|
|
73
|
+
href = value;
|
|
74
|
+
},
|
|
75
|
+
get href() {
|
|
76
|
+
return href;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const textNode = {
|
|
80
|
+
parentElement: {
|
|
81
|
+
closest: (selector: string) => (selector === "a[href]" ? link : null),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
installBucketingHandover(
|
|
86
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" },
|
|
87
|
+
() => "payload",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
listeners.get("click")?.({ target: textNode } as unknown as Event);
|
|
91
|
+
|
|
92
|
+
expect(href).toBe("https://checkout.example.com/next-step");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("does not append an unsigned payload during pointer prefetch", () => {
|
|
96
|
+
const listeners = new Map<string, EventListener>();
|
|
97
|
+
vi.stubGlobal("document", {
|
|
98
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
99
|
+
listeners.set(type, listener);
|
|
100
|
+
}),
|
|
101
|
+
removeEventListener: vi.fn(),
|
|
102
|
+
});
|
|
103
|
+
vi.stubGlobal("location", { href: "https://store.example.com/" });
|
|
104
|
+
|
|
105
|
+
let href = "https://checkout.example.com/next-step";
|
|
106
|
+
const link = {
|
|
107
|
+
closest: (selector: string) => (selector === "a[href]" ? link : null),
|
|
108
|
+
getAttribute: (name: string) => (name === "href" ? href : null),
|
|
109
|
+
set href(value: string) {
|
|
110
|
+
href = value;
|
|
111
|
+
},
|
|
112
|
+
get href() {
|
|
113
|
+
return href;
|
|
114
|
+
},
|
|
115
|
+
click: vi.fn(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
installBucketingHandover(
|
|
119
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" },
|
|
120
|
+
() => "payload",
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
listeners.get("pointerdown")?.({ target: link } as unknown as Event);
|
|
124
|
+
|
|
125
|
+
expect(href).toBe("https://checkout.example.com/next-step");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("resumes a first configured-domain click without dispatching merchant handlers twice", async () => {
|
|
129
|
+
vi.useFakeTimers();
|
|
130
|
+
vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
|
|
131
|
+
const listeners = new Map<string, EventListener>();
|
|
132
|
+
vi.stubGlobal("document", {
|
|
133
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
134
|
+
listeners.set(type, listener);
|
|
135
|
+
}),
|
|
136
|
+
removeEventListener: vi.fn(),
|
|
137
|
+
});
|
|
138
|
+
const assign = vi.fn();
|
|
139
|
+
vi.stubGlobal("location", { href: "https://store.example.com/", assign });
|
|
140
|
+
|
|
141
|
+
let href = "https://checkout.example.com/next-step";
|
|
142
|
+
const link = {
|
|
143
|
+
closest: (selector: string) => (selector === "a[href]" ? link : null),
|
|
144
|
+
getAttribute: (name: string) => (name === "href" ? href : null),
|
|
145
|
+
set href(value: string) {
|
|
146
|
+
href = value;
|
|
147
|
+
},
|
|
148
|
+
get href() {
|
|
149
|
+
return href;
|
|
150
|
+
},
|
|
151
|
+
click: vi.fn(),
|
|
152
|
+
};
|
|
153
|
+
let resolveMint: (value: string) => void = () => {};
|
|
154
|
+
const mint = vi.fn(() => new Promise<string>((resolve) => {
|
|
155
|
+
resolveMint = resolve;
|
|
156
|
+
}));
|
|
157
|
+
const basePayload = createBucketingHandoverPayload("visitor_3", [], { handoverTtlSeconds: 120 });
|
|
158
|
+
|
|
159
|
+
installBucketingHandover(
|
|
160
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket", signed: true },
|
|
161
|
+
() => basePayload,
|
|
162
|
+
mint,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const preventDefault = vi.fn();
|
|
166
|
+
listeners.get("click")?.({ type: "click", target: link, preventDefault } as unknown as Event);
|
|
167
|
+
expect(href).toBe("https://checkout.example.com/next-step");
|
|
168
|
+
expect(preventDefault).toHaveBeenCalledOnce();
|
|
169
|
+
expect(mint).toHaveBeenCalledTimes(1);
|
|
170
|
+
|
|
171
|
+
resolveMint(`${basePayload}.signature`);
|
|
172
|
+
await vi.waitFor(() => expect(assign).toHaveBeenCalledOnce());
|
|
173
|
+
|
|
174
|
+
expect(href).toBe(`https://checkout.example.com/next-step?apex_bucket=${basePayload}.signature`);
|
|
175
|
+
expect(assign).toHaveBeenCalledWith(href);
|
|
176
|
+
expect(link.click).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not reuse expired signed handover payloads", async () => {
|
|
180
|
+
vi.useFakeTimers();
|
|
181
|
+
vi.setSystemTime(new Date("2026-06-25T12:00:00.000Z"));
|
|
182
|
+
const listeners = new Map<string, EventListener>();
|
|
183
|
+
vi.stubGlobal("document", {
|
|
184
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
185
|
+
listeners.set(type, listener);
|
|
186
|
+
}),
|
|
187
|
+
removeEventListener: vi.fn(),
|
|
188
|
+
});
|
|
189
|
+
vi.stubGlobal("location", { href: "https://store.example.com/" });
|
|
190
|
+
|
|
191
|
+
let href = "https://checkout.example.com/next-step";
|
|
192
|
+
const link = {
|
|
193
|
+
closest: (selector: string) => (selector === "a[href]" ? link : null),
|
|
194
|
+
getAttribute: (name: string) => (name === "href" ? href : null),
|
|
195
|
+
set href(value: string) {
|
|
196
|
+
href = value;
|
|
197
|
+
},
|
|
198
|
+
get href() {
|
|
199
|
+
return href;
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
const initialBasePayload = createBucketingHandoverPayload("visitor_4", [], { handoverTtlSeconds: 120 });
|
|
203
|
+
const getPayload = vi.fn(() => createBucketingHandoverPayload("visitor_4", [], { handoverTtlSeconds: 120 }));
|
|
204
|
+
|
|
205
|
+
installBucketingHandover(
|
|
206
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket", signed: true },
|
|
207
|
+
getPayload,
|
|
208
|
+
async () => `${initialBasePayload}.signature`,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
listeners.get("pointerdown")?.({ target: link } as unknown as Event);
|
|
212
|
+
await Promise.resolve();
|
|
213
|
+
vi.setSystemTime(new Date("2026-06-25T12:02:01.000Z"));
|
|
214
|
+
href = "https://checkout.example.com/next-step";
|
|
215
|
+
listeners.get("click")?.({ target: link } as unknown as Event);
|
|
216
|
+
|
|
217
|
+
expect(href).toBe("https://checkout.example.com/next-step");
|
|
218
|
+
expect(href).not.toContain(`${initialBasePayload}.signature`);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("adds only signed handover payloads to configured external checkout forms", async () => {
|
|
222
|
+
const listeners = new Map<string, EventListener>();
|
|
223
|
+
const hiddenInputs: Array<{ type: string; name: string; value: string }> = [];
|
|
224
|
+
let action = "https://checkout.example.com/pay";
|
|
225
|
+
const form = {
|
|
226
|
+
tagName: "FORM",
|
|
227
|
+
getAttribute: (name: string) => (name === "action" ? "https://checkout.example.com/pay" : null),
|
|
228
|
+
set action(value: string) {
|
|
229
|
+
action = value;
|
|
230
|
+
},
|
|
231
|
+
get action() {
|
|
232
|
+
return action;
|
|
233
|
+
},
|
|
234
|
+
querySelector: () => null,
|
|
235
|
+
appendChild: (input: { type: string; name: string; value: string }) => {
|
|
236
|
+
hiddenInputs.push(input);
|
|
237
|
+
},
|
|
238
|
+
requestSubmit: vi.fn(),
|
|
239
|
+
};
|
|
240
|
+
vi.stubGlobal("document", {
|
|
241
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
242
|
+
listeners.set(type, listener);
|
|
243
|
+
}),
|
|
244
|
+
removeEventListener: vi.fn(),
|
|
245
|
+
createElement: vi.fn(() => ({ type: "", name: "", value: "" })),
|
|
246
|
+
});
|
|
247
|
+
vi.stubGlobal("location", { href: "https://store.example.com/" });
|
|
248
|
+
|
|
249
|
+
installBucketingHandover(
|
|
250
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" },
|
|
251
|
+
() => createBucketingHandoverPayload("visitor_form", []),
|
|
252
|
+
async () => `${createBucketingHandoverPayload("visitor_form", [])}.signature`,
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
const submitterAttributes = new Map([
|
|
256
|
+
["formaction", "https://checkout.example.com/express-pay"],
|
|
257
|
+
["formmethod", "post"],
|
|
258
|
+
]);
|
|
259
|
+
const submitter = {
|
|
260
|
+
getAttribute: (name: string) => submitterAttributes.get(name) ?? null,
|
|
261
|
+
hasAttribute: (name: string) => submitterAttributes.has(name),
|
|
262
|
+
setAttribute: (name: string, value: string) => submitterAttributes.set(name, value),
|
|
263
|
+
} as unknown as HTMLElement;
|
|
264
|
+
const preventDefault = vi.fn();
|
|
265
|
+
listeners.get("submit")?.({
|
|
266
|
+
target: form,
|
|
267
|
+
submitter,
|
|
268
|
+
preventDefault,
|
|
269
|
+
} as unknown as SubmitEvent);
|
|
270
|
+
await vi.waitFor(() => expect(form.requestSubmit).toHaveBeenCalledOnce());
|
|
271
|
+
|
|
272
|
+
expect(preventDefault).toHaveBeenCalledOnce();
|
|
273
|
+
expect(submitter.getAttribute("formaction")).toContain("https://checkout.example.com/express-pay?apex_bucket=");
|
|
274
|
+
expect(submitter.getAttribute("formaction")).toContain(".signature");
|
|
275
|
+
expect(submitter.getAttribute("formmethod")).toBe("post");
|
|
276
|
+
expect(hiddenInputs).toHaveLength(1);
|
|
277
|
+
expect(hiddenInputs[0]).toMatchObject({ type: "hidden", name: "apex_bucket" });
|
|
278
|
+
expect(hiddenInputs[0].value).toContain(".signature");
|
|
279
|
+
expect(form.requestSubmit).toHaveBeenCalledWith(submitter);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("does not inject handover inputs into non-configured forms", () => {
|
|
283
|
+
const listeners = new Map<string, EventListener>();
|
|
284
|
+
const hiddenInputs: Array<{ type: string; name: string; value: string }> = [];
|
|
285
|
+
const form = {
|
|
286
|
+
tagName: "FORM",
|
|
287
|
+
action: "https://store.example.com/account",
|
|
288
|
+
getAttribute: (name: string) => (name === "action" ? "/account" : null),
|
|
289
|
+
querySelector: () => null,
|
|
290
|
+
appendChild: (input: { type: string; name: string; value: string }) => {
|
|
291
|
+
hiddenInputs.push(input);
|
|
292
|
+
},
|
|
293
|
+
requestSubmit: vi.fn(),
|
|
294
|
+
};
|
|
295
|
+
vi.stubGlobal("document", {
|
|
296
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
297
|
+
listeners.set(type, listener);
|
|
298
|
+
}),
|
|
299
|
+
removeEventListener: vi.fn(),
|
|
300
|
+
createElement: vi.fn(() => ({ type: "", name: "", value: "" })),
|
|
301
|
+
});
|
|
302
|
+
vi.stubGlobal("location", { href: "https://store.example.com/" });
|
|
303
|
+
|
|
304
|
+
const mint = vi.fn(async () => "payload.signature");
|
|
305
|
+
installBucketingHandover(
|
|
306
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" },
|
|
307
|
+
() => "payload",
|
|
308
|
+
mint,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const preventDefault = vi.fn();
|
|
312
|
+
listeners.get("submit")?.({ target: form, preventDefault } as unknown as Event);
|
|
313
|
+
|
|
314
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
315
|
+
expect(mint).not.toHaveBeenCalled();
|
|
316
|
+
expect(form.requestSubmit).not.toHaveBeenCalled();
|
|
317
|
+
expect(hiddenInputs).toHaveLength(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("does not delay or redispatch same-domain link clicks while minting is available", () => {
|
|
321
|
+
const listeners = new Map<string, EventListener>();
|
|
322
|
+
vi.stubGlobal("document", {
|
|
323
|
+
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
|
324
|
+
listeners.set(type, listener);
|
|
325
|
+
}),
|
|
326
|
+
removeEventListener: vi.fn(),
|
|
327
|
+
});
|
|
328
|
+
vi.stubGlobal("location", { href: "https://store.example.com/" });
|
|
329
|
+
|
|
330
|
+
const link = {
|
|
331
|
+
closest: (selector: string) => (selector === "a[href]" ? link : null),
|
|
332
|
+
getAttribute: (name: string) => (name === "href" ? "/collections/new" : null),
|
|
333
|
+
click: vi.fn(),
|
|
334
|
+
};
|
|
335
|
+
const mint = vi.fn(async () => "payload.signature");
|
|
336
|
+
installBucketingHandover(
|
|
337
|
+
{ domains: ["checkout.example.com"], handoverQueryParam: "apex_bucket" },
|
|
338
|
+
() => "payload",
|
|
339
|
+
mint,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const preventDefault = vi.fn();
|
|
343
|
+
listeners.get("click")?.({ type: "click", target: link, preventDefault } as unknown as Event);
|
|
344
|
+
|
|
345
|
+
expect(preventDefault).not.toHaveBeenCalled();
|
|
346
|
+
expect(mint).not.toHaveBeenCalled();
|
|
347
|
+
expect(link.click).not.toHaveBeenCalled();
|
|
348
|
+
});
|
|
349
|
+
});
|