@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.
Files changed (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,40 @@
1
+ import { createQaAssistantStore, type QaAssistantStore } from "./qa-assistant-state";
2
+ import { mountQaAssistantView, type QaAssistantView } from "./qa-assistant-view";
3
+
4
+ let qaAssistantStore: QaAssistantStore | null = null;
5
+ let qaAssistantView: QaAssistantView | null = null;
6
+
7
+ /**
8
+ * Mounts the QA Assistant panel (store + shadow-DOM view). QA-only module:
9
+ * live/heatmap builds stub this file out entirely (see build.mjs), and the
10
+ * caller in index.ts additionally gates on QA mode.
11
+ */
12
+ export function mountQaAssistant(): QaAssistantView | null {
13
+ if (typeof document === "undefined") return null;
14
+ qaAssistantStore = qaAssistantStore ?? createQaAssistantStore();
15
+ if (qaAssistantView) return qaAssistantView;
16
+
17
+ const mount = () => {
18
+ if (!qaAssistantStore) return;
19
+ qaAssistantView = mountQaAssistantView(qaAssistantStore);
20
+ };
21
+ if (document.body) {
22
+ mount();
23
+ return qaAssistantView;
24
+ }
25
+ // Body not parsed yet — mounting is scheduled, which still counts as
26
+ // available (callers use null to fall back to the legacy toolbar).
27
+ document.addEventListener("DOMContentLoaded", mount, { once: true });
28
+ return {
29
+ destroy() {
30
+ document.removeEventListener("DOMContentLoaded", mount);
31
+ qaAssistantView?.destroy();
32
+ qaAssistantView = null;
33
+ },
34
+ };
35
+ }
36
+
37
+ export function getQaAssistantStore(): QaAssistantStore {
38
+ qaAssistantStore = qaAssistantStore ?? createQaAssistantStore();
39
+ return qaAssistantStore;
40
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * QA Toolbar — floating in-page toolbar for QA preview sessions.
3
+ *
4
+ * Renders inside a Shadow DOM to avoid style interference with the host page.
5
+ * Shows active experiments, variation switcher, and exit QA button.
6
+ */
7
+
8
+ import { Assignment, Experiment } from "./types";
9
+
10
+ export interface QaToolbarOptions {
11
+ experiments: Experiment[];
12
+ assignments: Assignment[];
13
+ forceMap: Record<string, string> | null;
14
+ qaSessionId?: string;
15
+ onSwitchVariation: (experimentId: string, variationId: string) => void;
16
+ onExitQa: () => void;
17
+ onReloadLatest?: () => void;
18
+ onExitHarness?: () => void;
19
+ }
20
+
21
+ const TOOLBAR_ID = "drip-qa-toolbar";
22
+
23
+ function serializeForceMap(forceMap: Record<string, string>): string {
24
+ return Object.entries(forceMap)
25
+ .map(([experimentId, variationId]) => `${experimentId}:${variationId}`)
26
+ .join(",");
27
+ }
28
+
29
+ function currentForceMap(options: QaToolbarOptions): Record<string, string> {
30
+ const map: Record<string, string> = { ...(options.forceMap ?? {}) };
31
+ for (const assignment of options.assignments) {
32
+ if (!map[assignment.experimentId]) {
33
+ map[assignment.experimentId] = assignment.variationId;
34
+ }
35
+ }
36
+ return map;
37
+ }
38
+
39
+ function buildQaDebugUrl(forceMap: Record<string, string>, qaSessionId?: string): string {
40
+ const url = new URL(location.href);
41
+ const entries = Object.entries(forceMap).filter(
42
+ ([experimentId, variationId]) => experimentId && variationId,
43
+ );
44
+ const filteredForceMap: Record<string, string> = {};
45
+ for (const [experimentId, variationId] of entries) {
46
+ filteredForceMap[experimentId] = variationId;
47
+ }
48
+ const force = serializeForceMap(filteredForceMap);
49
+ if (force) url.searchParams.set("drip_force", force);
50
+ if (entries.length === 1) {
51
+ url.searchParams.set("drip_experiment_id", entries[0][0]);
52
+ url.searchParams.set("drip_variation_id", entries[0][1]);
53
+ } else {
54
+ url.searchParams.delete("drip_experiment_id");
55
+ url.searchParams.delete("drip_variation_id");
56
+ }
57
+ url.searchParams.set("drip_qa", "1");
58
+ url.searchParams.set("drip_debug", "1");
59
+ if (qaSessionId) url.searchParams.set("drip_qa_session", qaSessionId);
60
+ return url.toString();
61
+ }
62
+
63
+ function replaceQaUrl(forceMap: Record<string, string>, qaSessionId?: string): void {
64
+ try {
65
+ history.replaceState(null, "", buildQaDebugUrl(forceMap, qaSessionId));
66
+ } catch {}
67
+ }
68
+
69
+ async function copyText(value: string): Promise<void> {
70
+ if (navigator.clipboard?.writeText) {
71
+ await navigator.clipboard.writeText(value);
72
+ return;
73
+ }
74
+ const textarea = document.createElement("textarea");
75
+ textarea.value = value;
76
+ textarea.style.position = "fixed";
77
+ textarea.style.opacity = "0";
78
+ document.body.appendChild(textarea);
79
+ textarea.select();
80
+ document.execCommand("copy");
81
+ document.body.removeChild(textarea);
82
+ }
83
+
84
+ const TOOLBAR_STYLES = `
85
+ :host {
86
+ all: initial;
87
+ position: fixed;
88
+ bottom: 16px;
89
+ right: 16px;
90
+ z-index: 2147483647;
91
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
92
+ }
93
+ .toolbar {
94
+ background: #1a1a2e;
95
+ border: 1px solid rgba(139, 92, 246, 0.4);
96
+ border-radius: 12px;
97
+ padding: 12px 16px;
98
+ color: #e2e8f0;
99
+ font-size: 13px;
100
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
101
+ min-width: 260px;
102
+ max-width: 360px;
103
+ }
104
+ .header {
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: space-between;
108
+ margin-bottom: 10px;
109
+ padding-bottom: 8px;
110
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
111
+ }
112
+ .badge {
113
+ background: #8b5cf6;
114
+ color: #fff;
115
+ font-size: 10px;
116
+ font-weight: 700;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0.5px;
119
+ padding: 3px 8px;
120
+ border-radius: 4px;
121
+ }
122
+ .exit-btn {
123
+ background: rgba(239, 68, 68, 0.15);
124
+ color: #ef4444;
125
+ border: 1px solid rgba(239, 68, 68, 0.3);
126
+ border-radius: 6px;
127
+ padding: 4px 10px;
128
+ font-size: 11px;
129
+ font-weight: 600;
130
+ cursor: pointer;
131
+ transition: background 0.15s;
132
+ }
133
+ .exit-btn:hover {
134
+ background: rgba(239, 68, 68, 0.25);
135
+ }
136
+ .tool-btn {
137
+ background: rgba(83, 193, 222, 0.12);
138
+ color: #7dd3fc;
139
+ border: 1px solid rgba(125, 211, 252, 0.25);
140
+ border-radius: 6px;
141
+ padding: 4px 8px;
142
+ font-size: 11px;
143
+ font-weight: 600;
144
+ cursor: pointer;
145
+ }
146
+ .debug-actions {
147
+ display: flex;
148
+ gap: 6px;
149
+ margin-bottom: 10px;
150
+ }
151
+ .debug-actions .tool-btn {
152
+ flex: 1;
153
+ min-width: 0;
154
+ }
155
+ .harness {
156
+ border: 1px solid rgba(125, 211, 252, 0.25);
157
+ border-radius: 8px;
158
+ padding: 8px;
159
+ margin-bottom: 10px;
160
+ color: #bae6fd;
161
+ background: rgba(14, 116, 144, 0.12);
162
+ }
163
+ .harness-title {
164
+ font-size: 11px;
165
+ font-weight: 700;
166
+ margin-bottom: 6px;
167
+ text-transform: uppercase;
168
+ }
169
+ .harness-actions {
170
+ display: flex;
171
+ gap: 6px;
172
+ }
173
+ .session {
174
+ border: 1px solid rgba(255, 255, 255, 0.1);
175
+ border-radius: 8px;
176
+ padding: 7px 8px;
177
+ margin-bottom: 10px;
178
+ background: rgba(255, 255, 255, 0.04);
179
+ }
180
+ .session-label {
181
+ color: #94a3b8;
182
+ font-size: 10px;
183
+ font-weight: 700;
184
+ letter-spacing: 0.04em;
185
+ margin-bottom: 3px;
186
+ text-transform: uppercase;
187
+ }
188
+ .session-id {
189
+ color: #e2e8f0;
190
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
191
+ font-size: 11px;
192
+ overflow: hidden;
193
+ text-overflow: ellipsis;
194
+ white-space: nowrap;
195
+ }
196
+ .experiment {
197
+ margin-bottom: 8px;
198
+ }
199
+ .experiment:last-child {
200
+ margin-bottom: 0;
201
+ }
202
+ .exp-name {
203
+ font-size: 12px;
204
+ color: #94a3b8;
205
+ margin-bottom: 4px;
206
+ white-space: nowrap;
207
+ overflow: hidden;
208
+ text-overflow: ellipsis;
209
+ }
210
+ .var-select {
211
+ width: 100%;
212
+ background: rgba(255, 255, 255, 0.06);
213
+ border: 1px solid rgba(255, 255, 255, 0.12);
214
+ border-radius: 6px;
215
+ color: #e2e8f0;
216
+ font-size: 13px;
217
+ padding: 6px 8px;
218
+ cursor: pointer;
219
+ outline: none;
220
+ appearance: none;
221
+ -webkit-appearance: none;
222
+ }
223
+ .var-select:focus {
224
+ border-color: #8b5cf6;
225
+ }
226
+ .var-select option {
227
+ background: #1a1a2e;
228
+ color: #e2e8f0;
229
+ }
230
+ .no-experiments {
231
+ color: #64748b;
232
+ font-size: 12px;
233
+ font-style: italic;
234
+ }
235
+ .collapse-btn {
236
+ background: none;
237
+ border: none;
238
+ color: #94a3b8;
239
+ cursor: pointer;
240
+ font-size: 16px;
241
+ padding: 0 4px;
242
+ line-height: 1;
243
+ }
244
+ .collapsed .body {
245
+ display: none;
246
+ }
247
+ .collapsed .toolbar {
248
+ min-width: auto;
249
+ }
250
+ `;
251
+
252
+ export function mountQaToolbar(options: QaToolbarOptions): void {
253
+ // Don't mount twice
254
+ if (document.getElementById(TOOLBAR_ID)) return;
255
+
256
+ const host = document.createElement("div");
257
+ host.id = TOOLBAR_ID;
258
+ const shadow = host.attachShadow({ mode: "closed" });
259
+
260
+ const style = document.createElement("style");
261
+ style.textContent = TOOLBAR_STYLES;
262
+ shadow.appendChild(style);
263
+
264
+ const toolbar = document.createElement("div");
265
+ toolbar.className = "toolbar";
266
+
267
+ // Header
268
+ const header = document.createElement("div");
269
+ header.className = "header";
270
+
271
+ const badge = document.createElement("span");
272
+ badge.className = "badge";
273
+ badge.textContent = "QA Preview";
274
+
275
+ const actions = document.createElement("div");
276
+ actions.style.cssText = "display:flex;gap:6px;align-items:center;";
277
+
278
+ const collapseBtn = document.createElement("button");
279
+ collapseBtn.className = "collapse-btn";
280
+ collapseBtn.textContent = "\u2212";
281
+ collapseBtn.title = "Collapse";
282
+ let collapsed = false;
283
+ collapseBtn.addEventListener("click", () => {
284
+ collapsed = !collapsed;
285
+ toolbar.classList.toggle("collapsed", collapsed);
286
+ collapseBtn.textContent = collapsed ? "+" : "\u2212";
287
+ });
288
+
289
+ const exitBtn = document.createElement("button");
290
+ exitBtn.className = "exit-btn";
291
+ exitBtn.textContent = "Exit QA";
292
+ exitBtn.addEventListener("click", options.onExitQa);
293
+
294
+ actions.appendChild(collapseBtn);
295
+ actions.appendChild(exitBtn);
296
+ header.appendChild(badge);
297
+ header.appendChild(actions);
298
+ toolbar.appendChild(header);
299
+
300
+ // Body
301
+ const body = document.createElement("div");
302
+ body.className = "body";
303
+ const activeForceMap = currentForceMap(options);
304
+
305
+ const harnessAssignment = options.assignments.find((assignment) => assignment.harnessSession);
306
+ if (harnessAssignment?.harnessSession) {
307
+ const harness = document.createElement("div");
308
+ harness.className = "harness";
309
+ const title = document.createElement("div");
310
+ title.className = "harness-title";
311
+ title.textContent = `Harness v${harnessAssignment.harnessSession.version ?? 0}`;
312
+ harness.appendChild(title);
313
+ const harnessActions = document.createElement("div");
314
+ harnessActions.className = "harness-actions";
315
+ const reload = document.createElement("button");
316
+ reload.className = "tool-btn";
317
+ reload.textContent = "Reload latest";
318
+ reload.addEventListener("click", () => options.onReloadLatest?.());
319
+ const exitHarness = document.createElement("button");
320
+ exitHarness.className = "tool-btn";
321
+ exitHarness.textContent = "Exit harness";
322
+ exitHarness.addEventListener("click", () => options.onExitHarness?.());
323
+ harnessActions.appendChild(reload);
324
+ harnessActions.appendChild(exitHarness);
325
+ harness.appendChild(harnessActions);
326
+ body.appendChild(harness);
327
+ }
328
+
329
+ if (options.experiments.length === 0) {
330
+ const noExp = document.createElement("div");
331
+ noExp.className = "no-experiments";
332
+ noExp.textContent = "No experiments on this page";
333
+ body.appendChild(noExp);
334
+ } else {
335
+ const debugActions = document.createElement("div");
336
+ debugActions.className = "debug-actions";
337
+ const copyDebugLink = document.createElement("button");
338
+ copyDebugLink.className = "tool-btn";
339
+ copyDebugLink.type = "button";
340
+ copyDebugLink.textContent = "Copy debug link";
341
+ copyDebugLink.title = "Copy a forced QA URL with experiment and variation ids";
342
+ copyDebugLink.addEventListener("click", () => {
343
+ const originalLabel = copyDebugLink.textContent;
344
+ copyText(buildQaDebugUrl(activeForceMap, options.qaSessionId))
345
+ .then(() => {
346
+ copyDebugLink.textContent = "Copied";
347
+ window.setTimeout(() => {
348
+ copyDebugLink.textContent = originalLabel;
349
+ }, 1600);
350
+ })
351
+ .catch(() => {
352
+ copyDebugLink.textContent = "Copy failed";
353
+ window.setTimeout(() => {
354
+ copyDebugLink.textContent = originalLabel;
355
+ }, 1600);
356
+ });
357
+ });
358
+
359
+ const openDebugPanel = document.createElement("button");
360
+ openDebugPanel.className = "tool-btn";
361
+ openDebugPanel.type = "button";
362
+ openDebugPanel.textContent = "Open debug panel";
363
+ openDebugPanel.title = "Enable Apex debug tools and reload this forced variation";
364
+ openDebugPanel.addEventListener("click", () => {
365
+ try {
366
+ localStorage.setItem("drip_devtools", "1");
367
+ } catch {}
368
+ replaceQaUrl(activeForceMap, options.qaSessionId);
369
+ location.reload();
370
+ });
371
+
372
+ debugActions.appendChild(copyDebugLink);
373
+ debugActions.appendChild(openDebugPanel);
374
+ body.appendChild(debugActions);
375
+
376
+ if (options.qaSessionId) {
377
+ const session = document.createElement("div");
378
+ session.className = "session";
379
+ const sessionLabel = document.createElement("div");
380
+ sessionLabel.className = "session-label";
381
+ sessionLabel.textContent = "QA session";
382
+ const sessionId = document.createElement("div");
383
+ sessionId.className = "session-id";
384
+ sessionId.textContent = options.qaSessionId;
385
+ sessionId.title = options.qaSessionId;
386
+ session.appendChild(sessionLabel);
387
+ session.appendChild(sessionId);
388
+ body.appendChild(session);
389
+ }
390
+
391
+ for (const exp of options.experiments) {
392
+ const expDiv = document.createElement("div");
393
+ expDiv.className = "experiment";
394
+
395
+ const nameEl = document.createElement("div");
396
+ nameEl.className = "exp-name";
397
+ nameEl.textContent = exp.id;
398
+ nameEl.title = exp.id;
399
+ expDiv.appendChild(nameEl);
400
+
401
+ const select = document.createElement("select");
402
+ select.className = "var-select";
403
+
404
+ const currentForced = options.forceMap?.[exp.id];
405
+ const activeForced = activeForceMap[exp.id];
406
+ const currentAssignment = options.assignments.find(
407
+ (a) => a.experimentId === exp.id,
408
+ );
409
+
410
+ for (const v of exp.variations) {
411
+ const opt = document.createElement("option");
412
+ opt.value = v.id;
413
+ const label = v.isControl ? "(Control)" : `Variation`;
414
+ opt.textContent = `${label} — ${v.id.slice(0, 8)}`;
415
+ if (
416
+ activeForced === v.id ||
417
+ currentForced === v.id ||
418
+ (!activeForced && !currentForced && currentAssignment?.variationId === v.id)
419
+ ) {
420
+ opt.selected = true;
421
+ }
422
+ select.appendChild(opt);
423
+ }
424
+
425
+ select.addEventListener("change", () => {
426
+ activeForceMap[exp.id] = select.value;
427
+ replaceQaUrl(activeForceMap, options.qaSessionId);
428
+ options.onSwitchVariation(exp.id, select.value);
429
+ });
430
+
431
+ expDiv.appendChild(select);
432
+ body.appendChild(expDiv);
433
+ }
434
+ }
435
+
436
+ toolbar.appendChild(body);
437
+ shadow.appendChild(toolbar);
438
+ document.body.appendChild(host);
439
+ }
440
+
441
+ export function unmountQaToolbar(): void {
442
+ const el = document.getElementById(TOOLBAR_ID);
443
+ if (el) el.remove();
444
+ }
@@ -0,0 +1,177 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { Assignment, Experiment } from "../types";
6
+ import {
7
+ DripProvider,
8
+ useFeatureFlag,
9
+ useTrackGoal,
10
+ useVariation,
11
+ } from "./index";
12
+
13
+ const sdkMock = vi.hoisted(() => ({
14
+ init: vi.fn(),
15
+ getAssignments: vi.fn(),
16
+ getExperiments: vi.fn(),
17
+ trackGoal: vi.fn(),
18
+ track: vi.fn(),
19
+ trackRevenue: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("../index", () => sdkMock);
23
+
24
+ function assignment(
25
+ experimentId: string,
26
+ variationId: string,
27
+ isControl = false,
28
+ ): Assignment {
29
+ return {
30
+ experimentId,
31
+ variationId,
32
+ index: 0,
33
+ mutations: [],
34
+ isControl,
35
+ hashValue: `${experimentId}:${variationId}`,
36
+ };
37
+ }
38
+
39
+ function resetSdkMock() {
40
+ sdkMock.init.mockReset();
41
+ sdkMock.getAssignments.mockReset();
42
+ sdkMock.getExperiments.mockReset();
43
+ sdkMock.trackGoal.mockReset();
44
+ sdkMock.track.mockReset();
45
+ sdkMock.trackRevenue.mockReset();
46
+ sdkMock.init.mockReturnValue([]);
47
+ sdkMock.getAssignments.mockReturnValue([]);
48
+ sdkMock.getExperiments.mockReturnValue([]);
49
+ }
50
+
51
+ function VariationLabel({ experimentId }: { experimentId: string }) {
52
+ const variation = useVariation(experimentId);
53
+ return <div data-testid="variation">{variation?.id ?? "none"}</div>;
54
+ }
55
+
56
+ function FlagLabel({ flagId }: { flagId: string }) {
57
+ const enabled = useFeatureFlag(flagId);
58
+ return <div data-testid={flagId}>{enabled ? "on" : "off"}</div>;
59
+ }
60
+
61
+ function GoalButton() {
62
+ const trackGoal = useTrackGoal();
63
+ return (
64
+ <button
65
+ type="button"
66
+ onClick={() => trackGoal("signup", { plan: "pro" })}
67
+ >
68
+ Track
69
+ </button>
70
+ );
71
+ }
72
+
73
+ describe("React bindings", () => {
74
+ beforeEach(() => {
75
+ resetSdkMock();
76
+ });
77
+
78
+ afterEach(() => {
79
+ cleanup();
80
+ vi.restoreAllMocks();
81
+ });
82
+
83
+ it("renders children and initializes the SDK", async () => {
84
+ const experiments: Experiment[] = [
85
+ {
86
+ id: "hero",
87
+ variations: [{ id: "control", weight: 1, mutations: [], isControl: true }],
88
+ },
89
+ ];
90
+ sdkMock.getExperiments.mockReturnValue(experiments);
91
+
92
+ render(
93
+ <DripProvider shopId="shop_123" endpoint="https://events.example.test">
94
+ <div>Storefront</div>
95
+ </DripProvider>,
96
+ );
97
+
98
+ expect(screen.getByText("Storefront")).toBeTruthy();
99
+ await waitFor(() => {
100
+ expect(sdkMock.init).toHaveBeenCalledWith(
101
+ expect.objectContaining({
102
+ shopId: "shop_123",
103
+ endpoint: "https://events.example.test",
104
+ }),
105
+ );
106
+ });
107
+ expect(sdkMock.getExperiments).toHaveBeenCalled();
108
+ });
109
+
110
+ it("returns null before assignments and updates variation after the initialized event", async () => {
111
+ const variant = assignment("hero-test", "variant-a");
112
+ sdkMock.getAssignments.mockReturnValue([]);
113
+
114
+ render(
115
+ <DripProvider shopId="shop_123">
116
+ <VariationLabel experimentId="hero-test" />
117
+ </DripProvider>,
118
+ );
119
+
120
+ expect(screen.getByTestId("variation").textContent).toBe("none");
121
+ await waitFor(() => expect(sdkMock.init).toHaveBeenCalled());
122
+
123
+ sdkMock.getAssignments.mockReturnValue([variant]);
124
+ await act(async () => {
125
+ document.dispatchEvent(new Event("drip_initialized"));
126
+ });
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByTestId("variation").textContent).toBe("variant-a");
130
+ });
131
+ });
132
+
133
+ it("enables feature flags only for non-control assignments", async () => {
134
+ sdkMock.init.mockReturnValue([
135
+ assignment("flag-enabled", "enabled"),
136
+ assignment("flag-control", "control", true),
137
+ ]);
138
+
139
+ render(
140
+ <DripProvider shopId="shop_123">
141
+ <FlagLabel flagId="flag-enabled" />
142
+ <FlagLabel flagId="flag-control" />
143
+ <FlagLabel flagId="flag-missing" />
144
+ </DripProvider>,
145
+ );
146
+
147
+ await waitFor(() => {
148
+ expect(screen.getByTestId("flag-enabled").textContent).toBe("on");
149
+ expect(screen.getByTestId("flag-control").textContent).toBe("off");
150
+ expect(screen.getByTestId("flag-missing").textContent).toBe("off");
151
+ });
152
+ });
153
+
154
+ it("throws when hooks are used outside DripProvider", () => {
155
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
156
+
157
+ expect(() => render(<VariationLabel experimentId="hero-test" />)).toThrow(
158
+ "useDrip must be used within a <DripProvider>",
159
+ );
160
+ spy.mockRestore();
161
+ });
162
+
163
+ it("proxies goal tracking to the SDK", async () => {
164
+ render(
165
+ <DripProvider shopId="shop_123">
166
+ <GoalButton />
167
+ </DripProvider>,
168
+ );
169
+
170
+ await waitFor(() => expect(sdkMock.init).toHaveBeenCalled());
171
+ fireEvent.click(screen.getByRole("button", { name: "Track" }));
172
+
173
+ await waitFor(() => {
174
+ expect(sdkMock.trackGoal).toHaveBeenCalledWith("signup", { plan: "pro" });
175
+ });
176
+ });
177
+ });