@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.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -5,6 +5,7 @@ import { mountQaAssistantView, QA_ASSISTANT_HOST_ID } from "./qa-assistant-view"
5
5
 
6
6
  function state(subline = "Showing Variant B"): QaAssistantState {
7
7
  return {
8
+ storeVersion: 0,
8
9
  experiments: [{
9
10
  id: "exp_products", name: "Product CTA", variationId: "var_b", variationName: "Variant B",
10
11
  applied: true, forced: false, subline,
@@ -19,7 +20,7 @@ function state(subline = "Showing Variant B"): QaAssistantState {
19
20
  { id: "var_b", name: "Variant B", isControl: false, isCurrent: true, isReal: true },
20
21
  ],
21
22
  }],
22
- events: [], transactions: { kind: "unsupported" }, forcedSummary: null,
23
+ events: [], goalScopes: [], pageActivation: [], transactions: { kind: "unsupported" }, forcedSummary: null, forcedUnavailable: [], edgeDeviceAudienceUnsupported: [],
23
24
  };
24
25
  }
25
26
 
@@ -31,7 +32,13 @@ function fakeStore(initial: QaAssistantState) {
31
32
  subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
32
33
  forceVariation: vi.fn(), resetForce: vi.fn(), exitQa: vi.fn(),
33
34
  };
34
- return { store, emit(next: QaAssistantState) { current = next; listeners.forEach((listener) => listener(next)); } };
35
+ return {
36
+ store,
37
+ emit(next: QaAssistantState) {
38
+ current = { ...next, storeVersion: current.storeVersion + 1 };
39
+ listeners.forEach((listener) => listener(current));
40
+ },
41
+ };
35
42
  }
36
43
 
37
44
  afterEach(() => {
@@ -40,6 +47,217 @@ afterEach(() => {
40
47
  });
41
48
 
42
49
  describe("mountQaAssistantView", () => {
50
+ it("gives the empty shadow host defensive inline styles", () => {
51
+ const view = mountQaAssistantView(fakeStore(state()).store);
52
+ const host = document.getElementById(QA_ASSISTANT_HOST_ID)!;
53
+
54
+ expect(host.childElementCount).toBe(0);
55
+ expect(host.style.position).toBe("fixed");
56
+ expect(host.style.zIndex).toBe("2147483647");
57
+ expect(host.style.display).toBe("block");
58
+ expect(host.style.top).toBe("0px");
59
+ expect(host.style.left).toBe("0px");
60
+ expect(host.style.width).toBe("0px");
61
+ expect(host.style.height).toBe("0px");
62
+ expect(host.style.overflow).toBe("visible");
63
+ expect(host.style.contain).toBe("none");
64
+ expect(host.getAttribute("style")).toContain("display: block !important");
65
+ view.destroy();
66
+ });
67
+
68
+ it("keeps the host displayed when storefront CSS hides empty divs", () => {
69
+ const storefrontStyle = document.createElement("style");
70
+ storefrontStyle.textContent = "div:empty { display: none; }";
71
+ document.head.appendChild(storefrontStyle);
72
+ const view = mountQaAssistantView(fakeStore(state()).store);
73
+ const host = document.getElementById(QA_ASSISTANT_HOST_ID)!;
74
+
75
+ expect(getComputedStyle(host).display).toBe("block");
76
+
77
+ view.destroy();
78
+ storefrontStyle.remove();
79
+ });
80
+
81
+ it("humanizes a skipped unresolved signal as an audience rule", () => {
82
+ const current = state();
83
+ current.experiments[0]!.conditions.push({
84
+ id: "segment:device:1",
85
+ group: "audience",
86
+ state: "skip",
87
+ text: "Desktop visitors",
88
+ detail: "Skipped — signal_unresolved failed first",
89
+ rawCode: null,
90
+ });
91
+ const source = fakeStore(current);
92
+ const view = mountQaAssistantView(source.store);
93
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
94
+ (shadow.querySelector('[data-action="toggle-exp"]') as HTMLElement).click();
95
+
96
+ expect(shadow.textContent).toContain("Skipped — the audience rule failed first");
97
+ view.destroy();
98
+ });
99
+
100
+ it("re-renders when Page activation is the only diagnostic change", () => {
101
+ vi.useFakeTimers();
102
+ const initial = {
103
+ ...state(),
104
+ pageActivation: [{
105
+ pageId: "page-products",
106
+ pageName: "Product pages",
107
+ active: true,
108
+ lastTransitionMode: null,
109
+ isolationFallback: null,
110
+ }],
111
+ };
112
+ const source = fakeStore(initial);
113
+ const view = mountQaAssistantView(source.store);
114
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
115
+
116
+ expect(shadow.querySelector('.scope-row[data-status="active"] .scope-status')?.textContent)
117
+ .toBe("Active");
118
+ source.emit({
119
+ ...initial,
120
+ pageActivation: [{
121
+ ...initial.pageActivation[0]!,
122
+ active: false,
123
+ lastTransitionMode: "deactivate",
124
+ }],
125
+ });
126
+ vi.advanceTimersByTime(100);
127
+
128
+ expect(shadow.querySelector('.scope-row[data-status="inactive"] .scope-status')?.textContent)
129
+ .toBe("Inactive");
130
+ expect(shadow.textContent).toContain("Last transition · Deactivate");
131
+ view.destroy();
132
+ });
133
+
134
+ it("shows per-Page active state and the last transition mode", () => {
135
+ const source = fakeStore({
136
+ ...state(),
137
+ pageActivation: [
138
+ {
139
+ pageId: "page-products",
140
+ pageName: "Product pages",
141
+ active: false,
142
+ lastTransitionMode: "deactivate",
143
+ isolationFallback: null,
144
+ },
145
+ {
146
+ pageId: "page-cart",
147
+ pageName: "Cart",
148
+ active: true,
149
+ lastTransitionMode: "reset",
150
+ isolationFallback: "whole_experiment",
151
+ },
152
+ ],
153
+ });
154
+ const view = mountQaAssistantView(source.store);
155
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
156
+
157
+ expect(shadow.textContent).toContain("Page activation");
158
+ expect(shadow.textContent).toContain("Product pages");
159
+ expect(shadow.textContent).toContain("Last transition · Deactivate");
160
+ expect(shadow.textContent).toContain("whole-experiment fallback");
161
+ view.destroy();
162
+ });
163
+
164
+ it("announces an unavailable forced experiment with its reason", () => {
165
+ const source = fakeStore({
166
+ ...state(),
167
+ forcedUnavailable: [{ experimentId: "exp_archived_123456", reason: "archived" }],
168
+ });
169
+ const view = mountQaAssistantView(source.store);
170
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
171
+
172
+ expect(shadow.querySelector('[role="alert"]')?.textContent).toContain(
173
+ "Forced experiment not available (archived): exp_arch…",
174
+ );
175
+ view.destroy();
176
+ });
177
+
178
+ it("names a device audience that falls back from edge to browser evaluation", () => {
179
+ const source = fakeStore({
180
+ ...state(),
181
+ edgeDeviceAudienceUnsupported: [{
182
+ experimentId: "exp_redirect",
183
+ audienceName: "Mobile shoppers",
184
+ }],
185
+ });
186
+ const view = mountQaAssistantView(source.store);
187
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
188
+
189
+ expect(shadow.querySelector('[role="alert"]')?.textContent).toContain(
190
+ "Audience “Mobile shoppers” cannot be evaluated at the edge. Apex falls back to evaluating it in the browser.",
191
+ );
192
+ view.destroy();
193
+ });
194
+
195
+ it("keeps resolved force summaries visible alongside unavailable force warnings", () => {
196
+ const source = fakeStore({
197
+ ...state(),
198
+ forcedSummary: "Product CTA: Variant B",
199
+ forcedUnavailable: [{ experimentId: "exp_archived_123456", reason: "archived" }],
200
+ });
201
+ const view = mountQaAssistantView(source.store);
202
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
203
+
204
+ expect(shadow.querySelector('[role="alert"]')?.textContent).toContain("exp_arch…");
205
+ expect(shadow.textContent).toContain("Product CTA: Variant B");
206
+ view.destroy();
207
+ });
208
+
209
+ it("does not render an unavailable warning when every force resolves", () => {
210
+ const source = fakeStore({ ...state(), forcedSummary: "Product CTA: Variant B" });
211
+ const view = mountQaAssistantView(source.store);
212
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
213
+
214
+ expect(shadow.querySelector('[role="alert"]')).toBeNull();
215
+ expect(shadow.textContent).toContain("Product CTA: Variant B");
216
+ view.destroy();
217
+ });
218
+
219
+ it("updates the unavailable warning when it is the only state change", () => {
220
+ vi.useFakeTimers();
221
+ const initial = state();
222
+ const source = fakeStore(initial);
223
+ const view = mountQaAssistantView(source.store);
224
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
225
+
226
+ source.emit({
227
+ ...initial,
228
+ forcedUnavailable: [{ experimentId: "exp_archived_123456", reason: "archived" }],
229
+ });
230
+ vi.advanceTimersByTime(100);
231
+ expect(shadow.querySelector('[role="alert"]')?.textContent).toContain(
232
+ "Forced experiment not available (archived): exp_arch…",
233
+ );
234
+
235
+ source.emit(initial);
236
+ vi.advanceTimersByTime(100);
237
+ expect(shadow.querySelector('[role="alert"]')).toBeNull();
238
+ view.destroy();
239
+ });
240
+
241
+ it("shows active, inactive, and not-wired goal scopes in Events", () => {
242
+ const source = fakeStore({
243
+ ...state(),
244
+ goalScopes: [
245
+ { id: "goal-active", name: "Add to cart", pageId: "page-products", pageLabel: "product", status: "active" },
246
+ { id: "goal-inactive", name: "Checkout click", pageId: "page-checkout", pageLabel: "checkout", status: "inactive" },
247
+ { id: "goal-unwired", name: "Signup", pageId: null, pageLabel: null, status: "not_wired" },
248
+ ],
249
+ });
250
+ const view = mountQaAssistantView(source.store);
251
+ const shadow = document.getElementById(QA_ASSISTANT_HOST_ID)!.shadowRoot!;
252
+ (shadow.querySelector('[data-action="tab"][data-tab="ev"]') as HTMLElement).click();
253
+
254
+ expect(shadow.textContent).toContain("Page · product");
255
+ expect(shadow.textContent).toContain("Wired · page active");
256
+ expect(shadow.textContent).toContain("Wired · page inactive");
257
+ expect(shadow.textContent).toContain("Not wired");
258
+ view.destroy();
259
+ });
260
+
43
261
  it("coalesces refresh bursts while preserving the panel, expanded rows, and scroll", () => {
44
262
  vi.useFakeTimers();
45
263
  const source = fakeStore(state());
@@ -27,6 +27,20 @@ type ViewState = {
27
27
 
28
28
  const MINIMIZED_KEY = "drip_qa_assistant_min";
29
29
 
30
+ const HOST_STYLES: ReadonlyArray<readonly [string, string]> = [
31
+ ["position", "fixed"],
32
+ ["z-index", "2147483647"],
33
+ ["display", "block"],
34
+ ["top", "0px"],
35
+ ["left", "0px"],
36
+ ["width", "0px"],
37
+ ["height", "0px"],
38
+ ["overflow", "visible"],
39
+ ["contain", "none"],
40
+ ["visibility", "visible"],
41
+ ["opacity", "1"],
42
+ ];
43
+
30
44
  /* ── icons (Lucide-style paths from the design's icon set) ─────────────── */
31
45
  const P = {
32
46
  check: "M5 13l4 4L19 7",
@@ -72,6 +86,8 @@ const REASON_WORDS: Record<string, string> = {
72
86
  url_exclude_matched: "page rules",
73
87
  page_rule_failed: "page rules",
74
88
  segment_mismatch: "the audience rule",
89
+ segments_unexpanded: "the unexpanded audience segments",
90
+ signal_unresolved: "the audience rule",
75
91
  segment_and_failed: "the audience rule",
76
92
  attribute_mismatch: "the audience rule",
77
93
  js_false: "the custom JS trigger",
@@ -138,6 +154,7 @@ button { font-family: inherit; background: none; border: 0; padding: 0; color: i
138
154
  border-bottom: 1px solid var(--p-bd); flex-shrink: 0; color: var(--p200); }
139
155
  .forced-bar .txt { font-size: 11px; font-weight: 600; flex: 1; min-width: 0; }
140
156
  .forced-bar .reset { font-size: 11px; font-weight: 600; color: var(--t1); white-space: nowrap; }
157
+ .forced-bar.warning { background: var(--warn-soft); border-color: var(--warn-bd); color: var(--warn); }
141
158
  .tabs { display: flex; padding: 0 8px; border-bottom: 1px solid var(--bd); background: rgba(255,255,255,0.015); flex-shrink: 0; }
142
159
  .tab { display: inline-flex; align-items: center; gap: 6px; padding: 10px 9px 9px; font-size: 12px; font-weight: 600;
143
160
  color: var(--t3); white-space: nowrap; }
@@ -199,6 +216,19 @@ button { font-family: inherit; background: none; border: 0; padding: 0; color: i
199
216
  .live .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--pos); box-shadow: 0 0 6px var(--pos); }
200
217
  .live .t { font-size: 11px; color: var(--t3); }
201
218
  .live .clear { margin-left: auto; font-size: 11px; color: var(--t4); font-weight: 600; }
219
+ .scope-list { padding: 0 14px 8px; border-bottom: 1px solid var(--bd); }
220
+ .scope-row { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid var(--bd); background: var(--inset); }
221
+ .scope-row + .scope-row { border-top: 0; }
222
+ .scope-row:first-child { border-radius: 8px 8px 0 0; }
223
+ .scope-row:last-child { border-radius: 0 0 8px 8px; }
224
+ .scope-row:only-child { border-radius: 8px; }
225
+ .scope-copy { display: grid; flex: 1; min-width: 0; }
226
+ .scope-name { overflow: hidden; font-size: 11.5px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
227
+ .scope-page { overflow: hidden; margin-top: 1px; color: var(--t3); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
228
+ .scope-status { flex-shrink: 0; color: var(--t3); font-size: 10.5px; font-weight: 600; }
229
+ .scope-row[data-status="active"] .scope-status { color: var(--pos); }
230
+ .scope-row[data-status="inactive"] { box-shadow: inset 2px 0 0 var(--warn); }
231
+ .scope-row[data-status="inactive"] .scope-status { color: var(--warn); }
202
232
  .ev { padding: 8px 14px; border-bottom: 1px solid var(--bd); }
203
233
  .ev:last-child { border-bottom: 0; }
204
234
  .ev[data-dup="true"] { background: rgba(245,158,11,0.05); box-shadow: inset 2px 0 0 var(--warn); }
@@ -368,6 +398,28 @@ function experimentsBody(state: QaAssistantState, view: ViewState): string {
368
398
  <select data-action="status-filter"><option value="all">All</option><option value="accepted" ${view.statusFilter === "accepted" ? "selected" : ""}>Applied</option><option value="rejected" ${view.statusFilter === "rejected" ? "selected" : ""}>Not applied</option><option value="forced" ${view.statusFilter === "forced" ? "selected" : ""}>Forced</option></select>
369
399
  <input data-action="search" aria-label="Search campaigns" placeholder="Search name or ID" value="${esc(view.search)}">
370
400
  </div>`;
401
+ const transitionLabels = {
402
+ reset: "Reset",
403
+ deactivate: "Deactivate",
404
+ force_reset: "Force-reset",
405
+ persistent: "Persistent",
406
+ } as const;
407
+ const pageActivation = state.pageActivation.length
408
+ ? `<div class="grp"><span class="l">Page activation</span><span class="n">${state.pageActivation.length}</span></div>` +
409
+ `<div class="scope-list">${state.pageActivation.map((page) => {
410
+ const transition = page.lastTransitionMode
411
+ ? `Last transition · ${transitionLabels[page.lastTransitionMode]}`
412
+ : "No transition yet";
413
+ const fallback = page.isolationFallback === "whole_experiment"
414
+ ? " · whole-experiment fallback"
415
+ : "";
416
+ const pollAttempts = page.triggerPollAttempts ?? 0;
417
+ const trigger = page.triggerDetector
418
+ ? ` · ${page.triggerDetector} detector · ${pollAttempts} poll attempt${pollAttempts === 1 ? "" : "s"} · ${page.triggerOutcome ?? "pending"} · ${page.triggerElapsedMs ?? 0} ms`
419
+ : "";
420
+ return `<div class="scope-row" data-status="${page.active ? "active" : "inactive"}"><span class="scope-copy"><span class="scope-name">${esc(page.pageName)}</span><span class="scope-page">${esc(transition + fallback + trigger)}</span></span><span class="scope-status">${page.active ? "Active" : "Inactive"}</span></div>`;
421
+ }).join("")}</div>`
422
+ : "";
371
423
  // Summary lines describe the whole campaign set, never the filtered view —
372
424
  // a filter that hides rejected rows must not make "all apply" claims.
373
425
  const totalRejected = state.experiments.filter((e) => !e.applied && !e.forced).length;
@@ -377,7 +429,7 @@ function experimentsBody(state: QaAssistantState, view: ViewState): string {
377
429
  } else if (state.experiments.length > 0 && totalRejected === state.experiments.length) {
378
430
  summary = `<div class="sumline" style="color:var(--t2)"><span style="color:var(--warn);display:inline-flex">${svg(P.alert, 12)}</span>Nothing runs on this page — every experiment was rejected.</div>`;
379
431
  } else if (state.experiments.length === 0) {
380
- return filters + `<div class="empty">
432
+ return pageActivation + filters + `<div class="empty">
381
433
  <div class="icon">${svg(P.target, 16)}</div>
382
434
  <div class="h">No experiments on this shop yet</div>
383
435
  <div class="s">Running and draft experiments show up here when the QA link opens a page they could target.</div>
@@ -391,7 +443,7 @@ function experimentsBody(state: QaAssistantState, view: ViewState): string {
391
443
  ? `<div class="grp"><span class="l">Not applied</span><span class="n">${rejected.length}</span></div>` +
392
444
  rejected.map((e) => experimentRow(e, view)).join("")
393
445
  : "";
394
- return filters + summary + appliedHtml + rejectedHtml;
446
+ return pageActivation + filters + summary + appliedHtml + rejectedHtml;
395
447
  }
396
448
 
397
449
  function eventRow(ev: QaEventModel): string {
@@ -417,6 +469,22 @@ function eventRow(ev: QaEventModel): string {
417
469
  }
418
470
 
419
471
  function eventsBody(state: QaAssistantState): string {
472
+ const goalScopes = state.goalScopes.length
473
+ ? `<div class="grp"><span class="l">Goal wiring</span><span class="n">${state.goalScopes.length}</span></div>` +
474
+ `<div class="scope-list">${state.goalScopes.map((goal) => {
475
+ const status = goal.status === "active"
476
+ ? goal.assignmentPageActive === false ? "Wired · page active · treatment off-target" : "Wired · page active"
477
+ : goal.status === "inactive"
478
+ ? goal.assignmentPageActive === false ? "Wired · page inactive · treatment off-target" : "Wired · page inactive"
479
+ : "Not wired";
480
+ const scope = goal.pageLabel
481
+ ? `Page · ${goal.pageLabel}`
482
+ : goal.urlPattern
483
+ ? `URL · ${goal.urlPattern}`
484
+ : "Project-wide";
485
+ return `<div class="scope-row" data-status="${goal.status}"><span class="scope-copy"><span class="scope-name">${esc(goal.name)}</span><span class="scope-page">${esc(scope)}</span></span><span class="scope-status">${status}</span></div>`;
486
+ }).join("")}</div>`
487
+ : "";
420
488
  const live = `
421
489
  <div class="live">
422
490
  <span class="dot"></span>
@@ -425,7 +493,7 @@ function eventsBody(state: QaAssistantState): string {
425
493
  </div>`;
426
494
  if (state.events.length === 0) {
427
495
  return (
428
- live +
496
+ goalScopes + live +
429
497
  `<div class="empty">
430
498
  <div class="icon">${svg(P.activity, 16)}</div>
431
499
  <div class="h">No events yet</div>
@@ -438,7 +506,7 @@ function eventsBody(state: QaAssistantState): string {
438
506
  .slice(0, 80)
439
507
  .map(eventRow)
440
508
  .join("");
441
- return live + rows;
509
+ return goalScopes + live + rows;
442
510
  }
443
511
 
444
512
  function attributionRows(list: Array<{ experimentName: string; variationName: string | null }>): string {
@@ -549,9 +617,31 @@ function panelHtml(state: QaAssistantState, view: ViewState): string {
549
617
  }
550
618
 
551
619
  function forcedBarHtml(state: QaAssistantState): string {
552
- return state.forcedSummary
620
+ let unavailableHtml = "";
621
+ if (state.forcedUnavailable.length > 0) {
622
+ const reasonLabels = {
623
+ archived: "archived",
624
+ runtime_disabled: "runtime-disabled",
625
+ not_found: "not found on this shop",
626
+ environment_mismatch: "scoped to another environment",
627
+ not_in_config: "not in served config",
628
+ } as const;
629
+ const warnings = state.forcedUnavailable.map(({ experimentId, reason }) =>
630
+ `Forced experiment not available (${reasonLabels[reason]}): ${shortId(experimentId)}`,
631
+ ).join(", ");
632
+ unavailableHtml = `<div class="forced-bar warning" role="alert">${svg(P.alert, 12)}<span class="txt">${esc(warnings)}</span><button class="reset" data-action="reset-force">Reset</button></div>`;
633
+ }
634
+ const summaryHtml = state.forcedSummary
553
635
  ? `<div class="forced-bar">${svg(P.bolt, 12)}<span class="txt">${esc(state.forcedSummary)}</span><button class="reset" data-action="reset-force">Reset</button></div>`
554
636
  : "";
637
+ const edgeAudienceHtml = state.edgeDeviceAudienceUnsupported.map(({ audienceName }) =>
638
+ `<div class="forced-bar warning" role="alert">${svg(P.alert, 12)}<span class="txt">${esc(`Audience “${audienceName}” cannot be evaluated at the edge. Apex falls back to evaluating it in the browser.`)}</span></div>`,
639
+ ).join("");
640
+ return unavailableHtml + edgeAudienceHtml + summaryHtml;
641
+ }
642
+
643
+ function shortId(id: string): string {
644
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
555
645
  }
556
646
 
557
647
  function pillHtml(state: QaAssistantState): string {
@@ -581,6 +671,9 @@ export function mountQaAssistantView(store: QaAssistantStore): QaAssistantView {
581
671
 
582
672
  const host = document.createElement("div");
583
673
  host.id = QA_ASSISTANT_HOST_ID;
674
+ for (const [property, value] of HOST_STYLES) {
675
+ host.style.setProperty(property, value, "important");
676
+ }
584
677
  const shadow = host.attachShadow({ mode: "open" });
585
678
  const style = document.createElement("style");
586
679
  style.textContent = CSS;
@@ -615,10 +708,14 @@ export function mountQaAssistantView(store: QaAssistantStore): QaAssistantView {
615
708
  };
616
709
 
617
710
  const signature = (state: QaAssistantState): string => JSON.stringify({
711
+ storeVersion: state.storeVersion,
618
712
  experiments: state.experiments,
619
713
  forcedSummary: state.forcedSummary,
714
+ forcedUnavailable: state.forcedUnavailable.map(({ experimentId, reason }) => [experimentId, reason]),
715
+ edgeDeviceAudienceUnsupported: state.edgeDeviceAudienceUnsupported.map(({ experimentId, audienceName }) => [experimentId, audienceName]),
620
716
  transactions: state.transactions,
621
717
  events: view.tab === "ev" ? state.events : undefined,
718
+ goalScopes: view.tab === "ev" ? state.goalScopes : undefined,
622
719
  view: [view.tab, view.minimized, [...view.expanded], [...view.codeOpen], view.pageFilter, view.statusFilter, view.search],
623
720
  });
624
721
 
@@ -6,6 +6,7 @@ import type { Assignment, Experiment } from "../types";
6
6
  import {
7
7
  DripProvider,
8
8
  useFeatureFlag,
9
+ useGetCartMetafields,
9
10
  useTrackGoal,
10
11
  useVariation,
11
12
  } from "./index";
@@ -17,6 +18,7 @@ const sdkMock = vi.hoisted(() => ({
17
18
  trackGoal: vi.fn(),
18
19
  track: vi.fn(),
19
20
  trackRevenue: vi.fn(),
21
+ getCartMetafields: vi.fn(),
20
22
  }));
21
23
 
22
24
  vi.mock("../index", () => sdkMock);
@@ -29,6 +31,7 @@ function assignment(
29
31
  return {
30
32
  experimentId,
31
33
  variationId,
34
+ pageActive: true,
32
35
  index: 0,
33
36
  mutations: [],
34
37
  isControl,
@@ -43,6 +46,7 @@ function resetSdkMock() {
43
46
  sdkMock.trackGoal.mockReset();
44
47
  sdkMock.track.mockReset();
45
48
  sdkMock.trackRevenue.mockReset();
49
+ sdkMock.getCartMetafields.mockReset();
46
50
  sdkMock.init.mockReturnValue([]);
47
51
  sdkMock.getAssignments.mockReturnValue([]);
48
52
  sdkMock.getExperiments.mockReturnValue([]);
@@ -70,6 +74,18 @@ function GoalButton() {
70
74
  );
71
75
  }
72
76
 
77
+ function CartMetafieldsButton() {
78
+ const getCartMetafields = useGetCartMetafields();
79
+ return (
80
+ <button
81
+ type="button"
82
+ onClick={() => getCartMetafields({ namespace: "merchant_bi" })}
83
+ >
84
+ Build cart metafields
85
+ </button>
86
+ );
87
+ }
88
+
73
89
  describe("React bindings", () => {
74
90
  beforeEach(() => {
75
91
  resetSdkMock();
@@ -174,4 +190,25 @@ describe("React bindings", () => {
174
190
  expect(sdkMock.trackGoal).toHaveBeenCalledWith("signup", { plan: "pro" });
175
191
  });
176
192
  });
193
+
194
+ it("exposes the cart-metafield callback through a public hook", async () => {
195
+ sdkMock.getCartMetafields.mockReturnValue([{
196
+ key: "merchant_bi.apex_assignments",
197
+ type: "json",
198
+ value: "[]",
199
+ }]);
200
+
201
+ render(
202
+ <DripProvider shopId="shop_123">
203
+ <CartMetafieldsButton />
204
+ </DripProvider>,
205
+ );
206
+
207
+ await waitFor(() => expect(sdkMock.init).toHaveBeenCalled());
208
+ fireEvent.click(screen.getByRole("button", { name: "Build cart metafields" }));
209
+
210
+ await waitFor(() => {
211
+ expect(sdkMock.getCartMetafields).toHaveBeenCalledWith({ namespace: "merchant_bi" });
212
+ });
213
+ });
177
214
  });
@@ -2,7 +2,7 @@
2
2
  * React bindings for Drip SDK.
3
3
  *
4
4
  * Usage:
5
- * import { DripProvider, useExperiment, useVariation, useFeatureFlag, useFeatureFlagValue, useTrackGoal } from "@drip-apex/sdk/react";
5
+ * import { DripProvider, useExperiment, useVariation, useFeatureFlag, useFeatureFlagValue, useTrackGoal, useGetCartMetafields } from "@drip-apex/sdk/react";
6
6
  *
7
7
  * function App() {
8
8
  * return (
@@ -40,6 +40,7 @@ import {
40
40
  } from "react";
41
41
  import type { Assignment, Experiment, InitConfig, Variation } from "../types";
42
42
  import type { RevenueEventData } from "../index";
43
+ import type { CartMetafieldInput } from "../shopify-cart";
43
44
 
44
45
  // ---------------------------------------------------------------------------
45
46
  // Context
@@ -58,6 +59,8 @@ interface DripContextValue {
58
59
  track: (type: string, data?: Record<string, unknown>) => void;
59
60
  /** Track revenue. */
60
61
  trackRevenue: (revenue: number, data?: RevenueEventData) => void;
62
+ /** Build headless Shopify cart metafields from the current attribution stamp. */
63
+ getCartMetafields: (options?: { namespace?: string }) => Promise<CartMetafieldInput[]>;
61
64
  }
62
65
 
63
66
  const DripContext = createContext<DripContextValue | null>(null);
@@ -140,9 +143,13 @@ export function DripProvider({
140
143
  import("../index").then((sdk) => sdk.trackRevenue(revenue, data));
141
144
  }, []);
142
145
 
146
+ const getCartMetafields = useCallback((options?: { namespace?: string }) => {
147
+ return import("../index").then((sdk) => sdk.getCartMetafields(options));
148
+ }, []);
149
+
143
150
  const value = useMemo<DripContextValue>(
144
- () => ({ assignments, experiments, loading, trackGoal, track, trackRevenue }),
145
- [assignments, experiments, loading, trackGoal, track, trackRevenue],
151
+ () => ({ assignments, experiments, loading, trackGoal, track, trackRevenue, getCartMetafields }),
152
+ [assignments, experiments, loading, trackGoal, track, trackRevenue, getCartMetafields],
146
153
  );
147
154
 
148
155
  return <DripContext.Provider value={value}>{children}</DripContext.Provider>;
@@ -232,6 +239,14 @@ export function useTrackRevenue(): (revenue: number, data?: Record<string, unkno
232
239
  return trackRevenue;
233
240
  }
234
241
 
242
+ /**
243
+ * Returns a function that builds the current Shopify cart-metafield mirror.
244
+ */
245
+ export function useGetCartMetafields(): DripContextValue["getCartMetafields"] {
246
+ const { getCartMetafields } = useDrip();
247
+ return getCartMetafields;
248
+ }
249
+
235
250
  /**
236
251
  * Get all current assignments.
237
252
  */
@@ -250,3 +265,4 @@ export function useLoading(): boolean {
250
265
 
251
266
  // Re-export types for convenience
252
267
  export type { Assignment, Experiment, Variation, InitConfig } from "../types";
268
+ export type { CartMetafieldInput } from "../shopify-cart";