@drip-apex/sdk 0.1.0 → 0.2.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 (245) 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 +19262 -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 +19246 -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 +16621 -13539
  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 +27 -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 +2070 -0
  128. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  129. package/src/consent-evaluation.ts +138 -0
  130. package/src/css.test.ts +30 -0
  131. package/src/css.ts +120 -6
  132. package/src/cwv.test.ts +60 -77
  133. package/src/debug-loader.test.ts +214 -0
  134. package/src/debug-loader.ts +41 -0
  135. package/src/decide-assignments.test.ts +196 -0
  136. package/src/decide-assignments.ts +204 -0
  137. package/src/delivery-timing.test.ts +104 -0
  138. package/src/delivery-timing.ts +89 -0
  139. package/src/early-interaction-capture.test.ts +451 -0
  140. package/src/early-interaction-capture.ts +230 -0
  141. package/src/early-reassert.test.ts +207 -0
  142. package/src/early-reassert.ts +152 -0
  143. package/src/edge-adoption.test.ts +435 -0
  144. package/src/edge-runtime.ts +30 -0
  145. package/src/editor/api.test.ts +109 -0
  146. package/src/editor/api.ts +34 -3
  147. package/src/editor/controller.test.ts +297 -14
  148. package/src/editor/controller.ts +69 -5
  149. package/src/editor/index.test.ts +2 -0
  150. package/src/editor/index.ts +1 -0
  151. package/src/editor/publication-result.ts +58 -0
  152. package/src/editor/types.ts +10 -1
  153. package/src/editor/view-kit.ts +2 -1
  154. package/src/editor/view.test.ts +108 -0
  155. package/src/editor/view.ts +49 -7
  156. package/src/endpoint.test.ts +15 -0
  157. package/src/endpoint.ts +10 -0
  158. package/src/exclusion-groups.ts +8 -18
  159. package/src/flags.test.ts +88 -1
  160. package/src/flags.ts +29 -29
  161. package/src/global-runtime-reconciler.test.ts +271 -0
  162. package/src/goal-eligibility.test.ts +29 -0
  163. package/src/goal-eligibility.ts +17 -0
  164. package/src/goals.test.ts +144 -1
  165. package/src/goals.ts +97 -18
  166. package/src/guards.test.ts +99 -1
  167. package/src/guards.ts +34 -15
  168. package/src/handover-cache-order.test.ts +6 -3
  169. package/src/handover.test.ts +154 -8
  170. package/src/handover.ts +122 -12
  171. package/src/hash.ts +29 -54
  172. package/src/holdout.test.ts +26 -0
  173. package/src/hydration.test.ts +73 -2
  174. package/src/hydration.ts +18 -9
  175. package/src/index.ts +130 -7287
  176. package/src/init-analytics.test.ts +1694 -0
  177. package/src/init-anti-flicker.test.ts +1211 -0
  178. package/src/init-cart-attributes.test.ts +2003 -0
  179. package/src/init-config-resolution.test.ts +999 -0
  180. package/src/init-consent.test.ts +971 -0
  181. package/src/init-empty.test.ts +11 -5406
  182. package/src/init-integrations.test.ts +846 -0
  183. package/src/init-page-activation.test.ts +5064 -0
  184. package/src/init-qa-loading.test.ts +740 -0
  185. package/src/init-redirects.test.ts +2156 -0
  186. package/src/init-test-helpers.ts +287 -0
  187. package/src/inline-bootstrap.test.ts +184 -0
  188. package/src/inline-bootstrap.ts +73 -0
  189. package/src/js.test.ts +51 -1
  190. package/src/js.ts +86 -2
  191. package/src/money.test.ts +55 -1
  192. package/src/money.ts +104 -2
  193. package/src/money.web-parity.contract.test.ts +85 -0
  194. package/src/mutate.test.ts +474 -10
  195. package/src/mutate.ts +739 -230
  196. package/src/page-trigger.test.ts +218 -8
  197. package/src/performance.ts +10 -0
  198. package/src/persistence-decoders.ts +18 -0
  199. package/src/public-api.ts +1828 -0
  200. package/src/qa-assistant-state.test.ts +249 -3
  201. package/src/qa-assistant-state.ts +146 -6
  202. package/src/qa-assistant-view.test.ts +220 -2
  203. package/src/qa-assistant-view.ts +102 -5
  204. package/src/react/index.test.tsx +37 -0
  205. package/src/react/index.tsx +19 -3
  206. package/src/reassert-sentinel.test.ts +155 -1
  207. package/src/reassert-sentinel.ts +168 -8
  208. package/src/redirect.test.ts +541 -0
  209. package/src/redirect.ts +572 -0
  210. package/src/replay-recorder-entry.ts +115 -0
  211. package/src/replay-signal-state.ts +100 -0
  212. package/src/replay-signals.test.ts +249 -0
  213. package/src/replay-signals.ts +151 -0
  214. package/src/replay.test.ts +764 -0
  215. package/src/replay.ts +514 -0
  216. package/src/router.test.ts +61 -2
  217. package/src/router.ts +50 -12
  218. package/src/runtime-state.ts +507 -0
  219. package/src/runtime.test.ts +174 -7
  220. package/src/runtime.ts +88 -14
  221. package/src/sanitize.test.ts +34 -0
  222. package/src/sanitize.ts +23 -12
  223. package/src/shadow.test.ts +187 -0
  224. package/src/shadow.ts +99 -0
  225. package/src/shopify-cart.test.ts +733 -23
  226. package/src/shopify-cart.ts +402 -49
  227. package/src/storefront-vitals.test.ts +217 -0
  228. package/src/storefront-vitals.ts +206 -0
  229. package/src/surface-signals.ts +6 -0
  230. package/src/targeting-conformance.test.ts +9 -0
  231. package/src/targeting-diagnostics.ts +38 -8
  232. package/src/targeting-parity-corpus.json +559 -0
  233. package/src/targeting-parity-corpus.test.ts +18 -0
  234. package/src/targeting.detailed.test.ts +105 -0
  235. package/src/targeting.test.ts +211 -0
  236. package/src/targeting.ts +88 -207
  237. package/src/track.test.ts +147 -0
  238. package/src/track.ts +125 -26
  239. package/src/types.ts +120 -13
  240. package/src/wait.test.ts +23 -0
  241. package/src/wait.ts +129 -13
  242. package/src/weekday-delivery.test.ts +56 -0
  243. package/src/weekday-schedule-parity-corpus.json +92 -0
  244. package/src/weekday-schedule.test.ts +69 -0
  245. package/src/weekday-schedule.ts +20 -0
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import type { QaAssistantStore } from "./qa-assistant-state";
3
+ import type { PageActivationDiagnostic } from "./types";
3
4
 
4
5
  type RuntimeSnapshot = ReturnType<typeof baseSnapshot>;
5
6
 
@@ -22,6 +23,12 @@ function baseSnapshot() {
22
23
  { id: "var_control", name: "Control", weight: 0.5, mutations: [], isControl: true },
23
24
  { id: "var_b", name: "Variant B", weight: 0.5, mutations: [] },
24
25
  ],
26
+ goals: [
27
+ { id: "goal_named", name: "Completed checkout", type: "custom" as const, eventName: "checkout_completed" },
28
+ { id: "goal_event", type: "custom" as const, eventName: "signup" },
29
+ { id: "goal_a", name: "Conversion", type: "custom" as const, eventName: "conversion_a" },
30
+ { id: "goal_b", name: "Conversion", type: "custom" as const, eventName: "conversion_b" },
31
+ ],
25
32
  },
26
33
  ],
27
34
  },
@@ -51,7 +58,17 @@ function baseSnapshot() {
51
58
  },
52
59
  ],
53
60
  },
61
+ pageActivation: [] as PageActivationDiagnostic[],
62
+ goalScopeUrl: null as string | null,
54
63
  forceMap: null as Record<string, string> | null,
64
+ forcedUnavailable: [] as Array<{
65
+ experimentId: string;
66
+ reason: "archived" | "runtime_disabled" | "not_found" | "not_in_config";
67
+ }>,
68
+ edgeDeviceAudienceUnsupported: [] as Array<{
69
+ experimentId: string;
70
+ audienceName: string;
71
+ }>,
55
72
  platform: "spa" as const,
56
73
  qaMode: true,
57
74
  orderIdentity: null as {
@@ -129,6 +146,131 @@ afterEach(() => {
129
146
  });
130
147
 
131
148
  describe("createQaAssistantStore", () => {
149
+ it("increments the store version on every state write", async () => {
150
+ const store = await loadStore();
151
+ const initialVersion = store.getState().storeVersion;
152
+
153
+ runtimeListener?.();
154
+ expect(store.getState().storeVersion).toBe(initialVersion + 1);
155
+
156
+ devtoolsListener?.({ ts: 1, type: "track", label: "pageview", detail: { path: "/next" } });
157
+ expect(store.getState().storeVersion).toBe(initialVersion + 2);
158
+ });
159
+
160
+ it("reports a configured Page-scoped goal as active or inactive", async () => {
161
+ vi.stubGlobal("location", { href: "https://shop.test/products/shirt" });
162
+ const store = await loadStore();
163
+ const experiment = snapshot.config.experiments[0] as typeof snapshot.config.experiments[0] & {
164
+ targeting?: unknown;
165
+ };
166
+ experiment.targeting = {
167
+ pageRules: [{
168
+ id: "page-products",
169
+ name: "product",
170
+ includeRules: [{ type: "simple", pattern: "shop.test/products/*" }],
171
+ }],
172
+ };
173
+ experiment.goals.push({
174
+ id: "goal-scoped",
175
+ name: "Add to cart",
176
+ type: "click",
177
+ pageId: "page-products",
178
+ } as never);
179
+ snapshot.pageActivation = [{
180
+ pageId: "page-products",
181
+ pageName: "product",
182
+ active: true,
183
+ appliedArtifactCount: 1,
184
+ }];
185
+ runtimeListener?.();
186
+
187
+ expect(store.getState().goalScopes).toContainEqual({
188
+ id: "goal-scoped",
189
+ name: "Add to cart",
190
+ pageId: "page-products",
191
+ pageLabel: "product",
192
+ urlPattern: null,
193
+ status: "active",
194
+ assignmentPageActive: null,
195
+ });
196
+
197
+ vi.stubGlobal("location", { href: "https://shop.test/collections/sale" });
198
+ snapshot.pageActivation[0]!.active = false;
199
+ runtimeListener?.();
200
+ expect(
201
+ store.getState().goalScopes.find((goal) => goal.id === "goal-scoped")?.status,
202
+ ).toBe("inactive");
203
+ });
204
+
205
+ it("uses the configured URL and normalized attributes for goal scope status", async () => {
206
+ vi.stubGlobal("location", { href: "https://shop.test/collections/sale" });
207
+ const store = await loadStore();
208
+ Object.assign(snapshot.config, {
209
+ url: "https://shop.test/products/shirt",
210
+ attributes: { device: "Mobile" },
211
+ });
212
+ const experiment = snapshot.config.experiments[0] as typeof snapshot.config.experiments[0] & {
213
+ targeting?: unknown;
214
+ };
215
+ experiment.targeting = {
216
+ pageRules: [{
217
+ id: "page-mobile-products",
218
+ name: "mobile product",
219
+ includeRules: [{ type: "simple", pattern: "shop.test/products/*" }],
220
+ advancedCondition: {
221
+ code: 'return attributes["device.type"] === "mobile";',
222
+ },
223
+ }],
224
+ };
225
+ experiment.goals.push({
226
+ id: "goal-mobile-product",
227
+ name: "Mobile add to cart",
228
+ type: "click",
229
+ pageId: "page-mobile-products",
230
+ } as never);
231
+ snapshot.pageActivation = [{
232
+ pageId: "page-mobile-products",
233
+ pageName: "mobile product",
234
+ active: true,
235
+ appliedArtifactCount: 1,
236
+ }];
237
+ runtimeListener?.();
238
+
239
+ expect(
240
+ store.getState().goalScopes.find((goal) => goal.id === "goal-mobile-product")?.status,
241
+ ).toBe("active");
242
+ });
243
+
244
+ it("uses the URL that current goal wiring used instead of the configured URL", async () => {
245
+ vi.stubGlobal("location", { href: "https://shop.test/collections/sale" });
246
+ const store = await loadStore();
247
+ Object.assign(snapshot.config, {
248
+ url: "https://shop.test/products/shirt",
249
+ });
250
+ snapshot.goalScopeUrl = "https://shop.test/collections/sale";
251
+ const experiment = snapshot.config.experiments[0] as typeof snapshot.config.experiments[0] & {
252
+ targeting?: unknown;
253
+ };
254
+ experiment.targeting = {
255
+ pageRules: [{
256
+ id: "page-products",
257
+ name: "product",
258
+ includeRules: [{ type: "simple", pattern: "shop.test/products/*" }],
259
+ }],
260
+ };
261
+ experiment.goals.push({
262
+ id: "goal-scoped",
263
+ name: "Add to cart",
264
+ type: "click",
265
+ pageId: "page-products",
266
+ } as never);
267
+ runtimeListener?.();
268
+
269
+ expect(
270
+ store.getState().goalScopes.find((goal) => goal.id === "goal-scoped")?.status,
271
+ ).toBe("inactive");
272
+ });
273
+
132
274
  it("maps condition diagnostics to pass, fail, skip, and error rows with raw JS", async () => {
133
275
  const store = await loadStore();
134
276
  snapshot.assignments = [];
@@ -370,7 +512,79 @@ describe("createQaAssistantStore", () => {
370
512
  });
371
513
  });
372
514
 
373
- it("marks the newer duplicate event within 1500ms", async () => {
515
+ it("shows required and detected device values for audience failures", async () => {
516
+ const store = await loadStore();
517
+ snapshot.activationDiagnostics.experiments[0].targetingConditions = [{
518
+ id: "segment:device:0",
519
+ label: "Device audience",
520
+ status: "failed",
521
+ reason: "segment_mismatch",
522
+ sourceType: "segment",
523
+ signal: "device.type",
524
+ operator: "equals",
525
+ expected: "Mobile",
526
+ actual: "Desktop",
527
+ }];
528
+ runtimeListener?.();
529
+ expect(store.getState().experiments[0].conditions[0]?.detail).toBe(
530
+ "Required: Mobile · Detected: Desktop",
531
+ );
532
+ });
533
+
534
+ it("shows an unknown detected device when the signal is unresolved", async () => {
535
+ const store = await loadStore();
536
+ snapshot.activationDiagnostics.experiments[0].targetingConditions = [{
537
+ id: "segment:device:0",
538
+ label: "Device audience",
539
+ status: "failed",
540
+ reason: "signal_unresolved",
541
+ sourceType: "segment",
542
+ signal: "device.type",
543
+ operator: "equals",
544
+ expected: "Mobile",
545
+ }];
546
+ runtimeListener?.();
547
+ expect(store.getState().experiments[0].conditions[0]?.detail).toBe(
548
+ "Required: Mobile · Detected: unknown",
549
+ );
550
+ });
551
+
552
+ it("resolves goal labels by configured name, event name, and short id", async () => {
553
+ const store = await loadStore();
554
+
555
+ devtoolsListener?.({
556
+ ts: 1000,
557
+ type: "track",
558
+ label: "goal",
559
+ detail: { goalId: "goal_named" },
560
+ });
561
+ devtoolsListener?.({
562
+ ts: 3000,
563
+ type: "track",
564
+ label: "goal",
565
+ detail: { goalId: "signup" },
566
+ });
567
+ devtoolsListener?.({
568
+ ts: 5000,
569
+ type: "track",
570
+ label: "goal",
571
+ detail: { goalId: "b6f8724b-1111-4222-8333-123456789abc" },
572
+ });
573
+
574
+ expect(store.getState().events).toEqual([
575
+ { at: 1000, type: "goal", label: "Completed checkout", value: null, goalId: "goal_named" },
576
+ { at: 3000, type: "goal", label: "signup", value: null, goalId: "signup" },
577
+ {
578
+ at: 5000,
579
+ type: "goal",
580
+ label: "b6f8724b",
581
+ value: null,
582
+ goalId: "b6f8724b-1111-4222-8333-123456789abc",
583
+ },
584
+ ]);
585
+ });
586
+
587
+ it("marks the newer duplicate goal event within 1500ms", async () => {
374
588
  const store = await loadStore();
375
589
 
376
590
  devtoolsListener?.({
@@ -387,8 +601,30 @@ describe("createQaAssistantStore", () => {
387
601
  });
388
602
 
389
603
  expect(store.getState().events).toEqual([
390
- { at: 1000, type: "goal", label: "signup", value: null },
391
- { at: 2200, type: "goal", label: "signup", value: null, duplicateOf: 1000 },
604
+ { at: 1000, type: "goal", label: "signup", value: null, goalId: "signup" },
605
+ { at: 2200, type: "goal", label: "signup", value: null, goalId: "signup", duplicateOf: 1000 },
606
+ ]);
607
+ });
608
+
609
+ it("does not merge distinct goal events with the same configured name", async () => {
610
+ const store = await loadStore();
611
+
612
+ devtoolsListener?.({
613
+ ts: 1000,
614
+ type: "track",
615
+ label: "goal",
616
+ detail: { goalId: "goal_a" },
617
+ });
618
+ devtoolsListener?.({
619
+ ts: 1200,
620
+ type: "track",
621
+ label: "goal",
622
+ detail: { goalId: "goal_b" },
623
+ });
624
+
625
+ expect(store.getState().events).toEqual([
626
+ { at: 1000, type: "goal", label: "Conversion", value: null, goalId: "goal_a" },
627
+ { at: 1200, type: "goal", label: "Conversion", value: null, goalId: "goal_b" },
392
628
  ]);
393
629
  });
394
630
 
@@ -443,6 +679,16 @@ describe("createQaAssistantStore", () => {
443
679
  });
444
680
  });
445
681
 
682
+ it("surfaces an unavailable forced experiment from the runtime", async () => {
683
+ const store = await loadStore();
684
+ snapshot.forcedUnavailable = [{ experimentId: "exp_archived", reason: "archived" }];
685
+ runtimeListener?.();
686
+
687
+ expect(store.getState().forcedUnavailable).toEqual([
688
+ { experimentId: "exp_archived", reason: "archived" },
689
+ ]);
690
+ });
691
+
446
692
  it("keeps a targeting-rejected force request non-forced in the experiment model", async () => {
447
693
  const store = await loadStore();
448
694
  snapshot.forceMap = { exp_123456789: "var_b" };
@@ -8,6 +8,8 @@ import type {
8
8
  import { exitQaMode } from "./guards";
9
9
  import type { DevtoolsEvent } from "./devtools";
10
10
  import { subscribeDevtoolsEvents } from "./devtools";
11
+ import { safeRegexTest } from "./safe-regex";
12
+ import { normalizeEventName } from "@drip/event-schema/event-name";
11
13
  import {
12
14
  __forceQaAssistantVariation,
13
15
  __getQaAssistantRuntimeSnapshot,
@@ -26,12 +28,40 @@ export type QaExperimentModel = {
26
28
  conditions: Array<{ id: string; group: "pages" | "audience"; state: "pass"|"fail"|"skip"|"error"; text: string; detail: string | null; rawCode: string | null }>;
27
29
  variations: Array<{ id: string; name: string; isControl: boolean; isCurrent: boolean; isReal: boolean }>;
28
30
  };
29
- export type QaEventModel = { at: number; type: "exposure"|"goal"|"revenue"|"pageview"; label: string; value: string | null; duplicateOf?: number };
31
+ export type QaEventModel = { at: number; type: "exposure"|"goal"|"revenue"|"pageview"; label: string; value: string | null; goalId?: string; duplicateOf?: number };
32
+ export type QaGoalScopeModel = {
33
+ id: string;
34
+ name: string;
35
+ pageId: string | null;
36
+ pageLabel: string | null;
37
+ urlPattern?: string | null;
38
+ status: "active" | "inactive" | "not_wired";
39
+ assignmentPageActive?: boolean | null;
40
+ };
30
41
  export type QaTransactionsModel =
31
42
  | { kind: "unsupported" }
32
43
  | { kind: "shopify"; cartStamped: boolean; variationName: string | null; stampedAt: number | null; attribution: Array<{ experimentName: string; variationName: string | null }> }
33
44
  | { kind: "order"; orderNumber: string | null; attribution: Array<{ experimentName: string; variationName: string | null }>; serverReceipt: "pending"|"confirmed"|"unknown" };
34
- export type QaAssistantState = { experiments: QaExperimentModel[]; events: QaEventModel[]; transactions: QaTransactionsModel; forcedSummary: string | null };
45
+ export type ForcedUnavailable = {
46
+ experimentId: string;
47
+ reason: "archived" | "runtime_disabled" | "not_found" | "environment_mismatch" | "not_in_config";
48
+ };
49
+ export type EdgeDeviceAudienceUnsupported = {
50
+ experimentId: string;
51
+ audienceName: string;
52
+ };
53
+ export type QaPageActivationModel = {
54
+ pageId: string;
55
+ pageName: string;
56
+ active: boolean;
57
+ lastTransitionMode: "reset" | "deactivate" | "force_reset" | "persistent" | null;
58
+ isolationFallback: "whole_experiment" | null;
59
+ triggerDetector?: "history" | "polling" | "dom" | null;
60
+ triggerPollAttempts?: number;
61
+ triggerOutcome?: "activated" | "condition_failed" | "window_expired" | null;
62
+ triggerElapsedMs?: number;
63
+ };
64
+ export type QaAssistantState = { storeVersion: number; experiments: QaExperimentModel[]; events: QaEventModel[]; goalScopes: QaGoalScopeModel[]; pageActivation: QaPageActivationModel[]; transactions: QaTransactionsModel; forcedSummary: string | null; forcedUnavailable: ForcedUnavailable[]; edgeDeviceAudienceUnsupported: EdgeDeviceAudienceUnsupported[] };
35
65
  export type QaAssistantStore = {
36
66
  getState(): QaAssistantState;
37
67
  subscribe(listener: (s: QaAssistantState) => void): () => void;
@@ -41,6 +71,7 @@ export type QaAssistantStore = {
41
71
  };
42
72
  export function createQaAssistantStore(): QaAssistantStore {
43
73
  let state = buildState([], { kind: "unsupported" });
74
+ let storeVersion = state.storeVersion;
44
75
  const listeners = new Set<(s: QaAssistantState) => void>();
45
76
  let cartFetchSignature: string | null = null;
46
77
  let disposed = false;
@@ -52,7 +83,8 @@ export function createQaAssistantStore(): QaAssistantStore {
52
83
  }
53
84
 
54
85
  function setState(next: QaAssistantState): void {
55
- state = next;
86
+ storeVersion += 1;
87
+ state = { ...next, storeVersion };
56
88
  emit();
57
89
  }
58
90
 
@@ -119,18 +151,95 @@ export function createQaAssistantStore(): QaAssistantStore {
119
151
 
120
152
  function buildState(events: QaEventModel[], transactions: QaTransactionsModel): QaAssistantState {
121
153
  const runtime = __getQaAssistantRuntimeSnapshot();
154
+ const experiments = runtime.config?.experiments ?? [];
122
155
  return {
156
+ storeVersion: 0,
123
157
  experiments: mapExperiments(
124
- runtime.config?.experiments ?? [],
158
+ experiments,
125
159
  runtime.assignments,
126
160
  runtime.activationDiagnostics.experiments,
127
161
  ),
128
162
  events,
163
+ goalScopes: mapGoalScopes(
164
+ experiments,
165
+ runtime.assignments,
166
+ runtime.goalScopeUrl ?? runtime.config?.url,
167
+ new Set(
168
+ runtime.pageActivation
169
+ .filter((page) => page.active)
170
+ .map((page) => page.pageId),
171
+ ),
172
+ ),
173
+ pageActivation: runtime.pageActivation.map((page) => ({
174
+ pageId: page.pageId,
175
+ pageName: page.pageName || shortId(page.pageId),
176
+ active: page.active,
177
+ lastTransitionMode: page.lastTransitionMode ?? null,
178
+ isolationFallback: page.isolationFallback ?? null,
179
+ triggerDetector: page.triggerDetector ?? null,
180
+ triggerPollAttempts: page.triggerPollAttempts ?? 0,
181
+ triggerOutcome: page.triggerOutcome ?? null,
182
+ triggerElapsedMs: page.triggerElapsedMs ?? 0,
183
+ })),
129
184
  transactions,
130
185
  forcedSummary: forcedSummary(runtime.config?.experiments ?? [], runtime.forceMap),
186
+ forcedUnavailable: runtime.forcedUnavailable,
187
+ edgeDeviceAudienceUnsupported: runtime.edgeDeviceAudienceUnsupported,
131
188
  };
132
189
  }
133
190
 
191
+ function mapGoalScopes(
192
+ experiments: Experiment[],
193
+ assignments: Assignment[],
194
+ configuredUrl?: string,
195
+ activePageIds: ReadonlySet<string> = new Set(),
196
+ ): QaGoalScopeModel[] {
197
+ const url = configuredUrl ??
198
+ (typeof location !== "undefined" ? location.href : "https://_");
199
+ const pageRules = experiments.flatMap((experiment) => [...(experiment.targeting?.pageRules ?? []), ...(experiment.goalPageRules ?? [])]);
200
+ const pageLabels = new Map(
201
+ pageRules.map((rule) => [rule.id, rule.name || shortId(rule.id)]),
202
+ );
203
+ const assignmentByExperiment = new Map(assignments.map((assignment) => [assignment.experimentId, assignment]));
204
+ const byGoal = new Map<string, QaGoalScopeModel>();
205
+
206
+ for (const experiment of experiments) {
207
+ for (const goal of experiment.goals ?? []) {
208
+ const assignment = assignmentByExperiment.get(experiment.id);
209
+ const wired = Boolean(assignment);
210
+ const active = wired && (goal.pageId
211
+ ? activePageIds.has(goal.pageId)
212
+ : !goal.urlPattern || safeRegexTest(goal.urlPattern, url));
213
+ const status: QaGoalScopeModel["status"] = active
214
+ ? "active"
215
+ : wired
216
+ ? "inactive"
217
+ : "not_wired";
218
+ const existing = byGoal.get(goal.id);
219
+ if (
220
+ existing &&
221
+ (existing.status === "active" ||
222
+ (existing.status === "inactive" && status === "not_wired"))
223
+ ) {
224
+ continue;
225
+ }
226
+ byGoal.set(goal.id, {
227
+ id: goal.id,
228
+ name: goal.name || goal.eventName || shortId(goal.id),
229
+ pageId: goal.pageId ?? null,
230
+ pageLabel: goal.pageId
231
+ ? pageLabels.get(goal.pageId) ?? shortId(goal.pageId)
232
+ : null,
233
+ urlPattern: goal.urlPattern ?? null,
234
+ status,
235
+ assignmentPageActive: assignment?.pageActive ?? null,
236
+ });
237
+ }
238
+ }
239
+
240
+ return Array.from(byGoal.values());
241
+ }
242
+
134
243
  function mapExperiments(
135
244
  experiments: Experiment[],
136
245
  assignments: Assignment[],
@@ -216,6 +325,15 @@ function conditionDetail(condition: ConditionDiagnostic, isJsError: boolean): st
216
325
  if (condition.status === "skipped") {
217
326
  return `Skipped — ${condition.reason || "an earlier gate"} failed first`;
218
327
  }
328
+ if (condition.signal === "device.type" && condition.status === "failed") {
329
+ const detected =
330
+ condition.actual === undefined ||
331
+ condition.actual === null ||
332
+ (typeof condition.actual === "string" && condition.actual.trim() === "")
333
+ ? "unknown"
334
+ : formatValue(condition.actual);
335
+ return `Required: ${formatValue(condition.expected)} · Detected: ${detected}`;
336
+ }
219
337
  const parts: string[] = [];
220
338
  if (condition.signal) parts.push(`Signal: ${condition.signal}`);
221
339
  if (condition.operator) parts.push(`Operator: ${condition.operator}`);
@@ -409,6 +527,16 @@ function variationNameById(experiments: Experiment[], experimentId: string, vari
409
527
  return variation ? variationDisplayName(variation, index) : shortId(variationId);
410
528
  }
411
529
 
530
+ function goalNameById(experiments: Experiment[], goalId: string): string {
531
+ const goals = experiments.flatMap((experiment) => experiment.goals ?? []);
532
+ const normalizedGoalId = normalizeEventName(goalId);
533
+ const goal = goals.find((item) => item.id === goalId)
534
+ ?? goals.find(
535
+ (item) => item.eventName && normalizeEventName(item.eventName) === normalizedGoalId,
536
+ );
537
+ return goal?.name || goal?.eventName || shortId(goalId);
538
+ }
539
+
412
540
  function variationDisplayName(variation: Variation, index: number): string {
413
541
  return variation.name || (variation.isControl ? "Control" : shortId(variation.id || `variation-${index + 1}`));
414
542
  }
@@ -427,6 +555,9 @@ function mapDevtoolsEvent(event: DevtoolsEvent): QaEventModel | null {
427
555
  type,
428
556
  label: eventLabel(type, event.label, detail),
429
557
  value: eventValue(type, detail),
558
+ ...(type === "goal" && typeof detail.goalId === "string"
559
+ ? { goalId: detail.goalId }
560
+ : {}),
430
561
  };
431
562
  }
432
563
 
@@ -438,7 +569,11 @@ function eventType(label: string, detail: Record<string, unknown>): QaEventModel
438
569
  }
439
570
 
440
571
  function eventLabel(type: QaEventModel["type"], label: string, detail: Record<string, unknown>): string {
441
- if (type === "goal") return typeof detail.goalId === "string" ? detail.goalId : "Goal";
572
+ if (type === "goal") {
573
+ if (typeof detail.goalId !== "string") return "Goal";
574
+ const runtime = __getQaAssistantRuntimeSnapshot();
575
+ return goalNameById(runtime.config?.experiments ?? [], detail.goalId);
576
+ }
442
577
  if (type === "revenue") return "Revenue";
443
578
  if (type === "pageview") return typeof detail.path === "string" ? detail.path : "Pageview";
444
579
  const runtime = __getQaAssistantRuntimeSnapshot();
@@ -465,9 +600,14 @@ function appendEvent(events: QaEventModel[], event: QaEventModel): QaEventModel[
465
600
  let duplicate: QaEventModel | undefined;
466
601
  for (let index = events.length - 1; index >= 0; index -= 1) {
467
602
  const candidate = events[index];
603
+ const sameIdentity = event.type === "goal" && candidate.type === "goal"
604
+ ? event.goalId != null && candidate.goalId != null
605
+ ? candidate.goalId === event.goalId
606
+ : candidate.label === event.label
607
+ : candidate.label === event.label;
468
608
  if (
469
609
  candidate.type === event.type &&
470
- candidate.label === event.label &&
610
+ sameIdentity &&
471
611
  event.at - candidate.at <= 1500
472
612
  ) {
473
613
  duplicate = candidate;