@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,68 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { evaluate } from "./index";
3
+ import type { Experiment } from "./types";
4
+
5
+ function personalizationRule(id: string, ruleId: string, stopAfterMatch: boolean): Experiment {
6
+ return {
7
+ id,
8
+ kind: "personalization",
9
+ status: "running",
10
+ trafficAllocation: 1,
11
+ personalization: {
12
+ id: "personalization_1",
13
+ key: "homepage-banner",
14
+ ruleId,
15
+ experienceId: `${ruleId}_experience`,
16
+ surface: "home",
17
+ templateKey: "hero",
18
+ priority: ruleId === "rule_first" ? 10 : 20,
19
+ stopAfterMatch,
20
+ },
21
+ variations: [
22
+ {
23
+ id: `${ruleId}_variant`,
24
+ weight: 1,
25
+ mutations: [],
26
+ value: { ruleId },
27
+ },
28
+ ],
29
+ };
30
+ }
31
+
32
+ describe("personalization assignment ordering", () => {
33
+ it("keeps the first matching rule when stopAfterMatch is enabled", () => {
34
+ const assignments = evaluate(
35
+ [
36
+ personalizationRule("personalization:homepage-banner:rule_first", "rule_first", true),
37
+ personalizationRule("personalization:homepage-banner:rule_second", "rule_second", true),
38
+ ],
39
+ {
40
+ url: "https://shop.test/",
41
+ attributes: { "page.type": "home" },
42
+ userId: "visitor-1",
43
+ },
44
+ );
45
+
46
+ expect(assignments).toHaveLength(1);
47
+ expect(assignments[0]?.personalization?.ruleId).toBe("rule_first");
48
+ });
49
+
50
+ it("allows lower-priority rules when the earlier rule does not stop matching", () => {
51
+ const assignments = evaluate(
52
+ [
53
+ personalizationRule("personalization:homepage-banner:rule_first", "rule_first", false),
54
+ personalizationRule("personalization:homepage-banner:rule_second", "rule_second", true),
55
+ ],
56
+ {
57
+ url: "https://shop.test/",
58
+ attributes: { "page.type": "home" },
59
+ userId: "visitor-1",
60
+ },
61
+ );
62
+
63
+ expect(assignments.map((assignment) => assignment.personalization?.ruleId)).toEqual([
64
+ "rule_first",
65
+ "rule_second",
66
+ ]);
67
+ });
68
+ });
@@ -0,0 +1,17 @@
1
+ const PII_KEY_TOKENS = new Set([
2
+ "email", "mail", "phone", "tel", "mobile", "fax",
3
+ "address", "street", "zip", "postal", "postcode",
4
+ "firstname", "lastname", "fullname", "surname", "username", "name",
5
+ "birth", "birthday", "birthdate", "dob", "gender",
6
+ "ssn", "vat", "iban", "bic", "card", "cvv", "cvc",
7
+ "password", "passwd", "secret", "token", "auth", "session",
8
+ "ip", "latitude", "longitude", "lat", "lng", "lon",
9
+ ]);
10
+
11
+ export function containsPiiToken(key: string): boolean {
12
+ return key
13
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
14
+ .toLowerCase()
15
+ .split(/[^a-z0-9]+/)
16
+ .some((token) => PII_KEY_TOKENS.has(token));
17
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildPrerequisiteContext, evaluateProjectPrerequisite } from "./prerequisites";
3
+
4
+ const baseConfig = {
5
+ experiments: [],
6
+ requireConsent: true,
7
+ hasConsent: false,
8
+ url: "https://example.com/products/a",
9
+ };
10
+
11
+ describe("project prerequisites", () => {
12
+ it("evaluates built-in rules without using dynamic code execution", () => {
13
+ const context = buildPrerequisiteContext(baseConfig, "visitor_1", true, false, "start_tracking");
14
+ const OriginalFunction = globalThis.Function;
15
+ try {
16
+ (globalThis as any).Function = function Function() {
17
+ throw new Error("unsafe-eval blocked");
18
+ };
19
+
20
+ expect(evaluateProjectPrerequisite("return true;", context, false)).toBe(true);
21
+ expect(evaluateProjectPrerequisite("return false;", context, true)).toBe(false);
22
+ expect(evaluateProjectPrerequisite("return drip.get('trackingConsentEnabled');", context, true)).toBe(false);
23
+ expect(evaluateProjectPrerequisite("return context.consentGranted;", context, true)).toBe(false);
24
+ } finally {
25
+ (globalThis as any).Function = OriginalFunction;
26
+ }
27
+ });
28
+
29
+ it("still supports custom rules when dynamic execution is available", () => {
30
+ const context = buildPrerequisiteContext(baseConfig, "visitor_1", true, true, "show_variations");
31
+ expect(
32
+ evaluateProjectPrerequisite(
33
+ "return drip.get('url').includes('/products/') && drip.get('consentGranted');",
34
+ context,
35
+ false,
36
+ ),
37
+ ).toBe(true);
38
+ });
39
+ });
@@ -0,0 +1,94 @@
1
+ import { debugWarn } from "./debug";
2
+ import type { InitConfig } from "./types";
3
+
4
+ declare const __DRIP_FEATURE_DEVTOOLS__: boolean;
5
+
6
+ type PrerequisiteContext = {
7
+ type: "show_variations" | "start_tracking";
8
+ url: string;
9
+ attributes: Record<string, unknown>;
10
+ userId: string | null;
11
+ consentRequired: boolean;
12
+ consentGranted: boolean;
13
+ };
14
+
15
+ function getWindowValue(key: string): unknown {
16
+ const w = globalThis as Record<string, unknown>;
17
+ return w[key] ?? (w.__drip as Record<string, unknown> | undefined)?.[key];
18
+ }
19
+
20
+ function buildRuleApi(context: PrerequisiteContext) {
21
+ return {
22
+ get(key: string): unknown {
23
+ if (key === "trackingConsentEnabled") {
24
+ return !context.consentRequired || context.consentGranted;
25
+ }
26
+ if (key === "consentGranted") return context.consentGranted;
27
+ if (key === "url") return context.url;
28
+ if (key === "userId") return context.userId;
29
+ return getWindowValue(key);
30
+ },
31
+ context,
32
+ };
33
+ }
34
+
35
+ function normalizeSimpleRule(code: string): string {
36
+ return code.trim().replace(/\s+/g, " ").replace(/;+$/, "");
37
+ }
38
+
39
+ function evaluateKnownRule(code: string, context: PrerequisiteContext): boolean | null {
40
+ const normalized = normalizeSimpleRule(code);
41
+ if (normalized === "return true") return true;
42
+ if (normalized === "return false") return false;
43
+ if (normalized === "return context.consentGranted") return context.consentGranted;
44
+ if (normalized === "return !context.consentRequired || context.consentGranted") {
45
+ return !context.consentRequired || context.consentGranted;
46
+ }
47
+
48
+ const getterMatch = normalized.match(/^return\s+(?:drip|Drip)\.get\(['"]([a-zA-Z0-9_.-]+)['"]\)$/);
49
+ if (getterMatch) {
50
+ const api = buildRuleApi(context);
51
+ return api.get(getterMatch[1]) === true;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ export function evaluateProjectPrerequisite(
58
+ rule: string | undefined,
59
+ context: PrerequisiteContext,
60
+ fallback: boolean,
61
+ ): boolean {
62
+ const code = (rule ?? "").trim();
63
+ if (!code) return fallback;
64
+ const knownResult = evaluateKnownRule(code, context);
65
+ if (knownResult != null) return knownResult;
66
+ try {
67
+ const api = buildRuleApi(context);
68
+ const fn = new Function("context", "drip", "Drip", code);
69
+ const result = fn.call(globalThis, context, api, api);
70
+ return result === true;
71
+ } catch (err) {
72
+ __DRIP_FEATURE_DEVTOOLS__ && debugWarn("prerequisite", `${context.type} rule failed; failing closed`, {
73
+ error: err instanceof Error ? err.message : String(err),
74
+ });
75
+ return false;
76
+ }
77
+ }
78
+
79
+ export function buildPrerequisiteContext(
80
+ config: InitConfig,
81
+ userId: string | null,
82
+ consentRequired: boolean,
83
+ consentGranted: boolean,
84
+ type: PrerequisiteContext["type"],
85
+ ): PrerequisiteContext {
86
+ return {
87
+ type,
88
+ url: config.url ?? location.href,
89
+ attributes: config.attributes ?? {},
90
+ userId,
91
+ consentRequired,
92
+ consentGranted,
93
+ };
94
+ }
@@ -0,0 +1,471 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { QaAssistantStore } from "./qa-assistant-state";
3
+
4
+ type RuntimeSnapshot = ReturnType<typeof baseSnapshot>;
5
+
6
+ let snapshot: RuntimeSnapshot;
7
+ let runtimeListener: (() => void) | null = null;
8
+ let devtoolsListener: ((event: { ts: number; type: string; label: string; detail?: Record<string, unknown> }) => void) | null = null;
9
+ let forceMock: ReturnType<typeof vi.fn>;
10
+ let resetMock: ReturnType<typeof vi.fn>;
11
+
12
+ function baseSnapshot() {
13
+ return {
14
+ config: {
15
+ profile: { platform: "spa" as const, version: 1 as const },
16
+ experiments: [
17
+ {
18
+ id: "exp_123456789",
19
+ name: "Checkout CTA",
20
+ status: "running",
21
+ variations: [
22
+ { id: "var_control", name: "Control", weight: 0.5, mutations: [], isControl: true },
23
+ { id: "var_b", name: "Variant B", weight: 0.5, mutations: [] },
24
+ ],
25
+ },
26
+ ],
27
+ },
28
+ assignments: [
29
+ {
30
+ experimentId: "exp_123456789",
31
+ experimentName: "Checkout CTA",
32
+ variationId: "var_b",
33
+ index: 1,
34
+ mutations: [],
35
+ isControl: false,
36
+ hashValue: "visitor_1",
37
+ },
38
+ ],
39
+ activationDiagnostics: {
40
+ project: { gates: [] },
41
+ experiments: [
42
+ {
43
+ experimentId: "exp_123456789",
44
+ experimentName: "Checkout CTA",
45
+ status: "passed" as const,
46
+ assignedVariationId: "var_b",
47
+ assignedVariationIndex: 1,
48
+ forced: false,
49
+ gates: [],
50
+ targetingConditions: [],
51
+ },
52
+ ],
53
+ },
54
+ forceMap: null as Record<string, string> | null,
55
+ platform: "spa" as const,
56
+ qaMode: true,
57
+ orderIdentity: null as {
58
+ platform: "shopify" | "shopware" | "woocommerce" | "centra" | "custom";
59
+ platformOrderId: string | null;
60
+ orderNumber: string | null;
61
+ } | null,
62
+ };
63
+ }
64
+
65
+ async function loadStore(): Promise<QaAssistantStore> {
66
+ vi.resetModules();
67
+ snapshot = baseSnapshot();
68
+ runtimeListener = null;
69
+ devtoolsListener = null;
70
+ forceMock = vi.fn((experimentId: string, variationId: string) => {
71
+ snapshot.forceMap = { ...(snapshot.forceMap ?? {}), [experimentId]: variationId };
72
+ snapshot.assignments = snapshot.assignments.map((assignment) =>
73
+ assignment.experimentId === experimentId ? { ...assignment, variationId } : assignment
74
+ );
75
+ snapshot.activationDiagnostics.experiments = snapshot.activationDiagnostics.experiments.map(
76
+ (diagnostic) => diagnostic.experimentId === experimentId
77
+ ? {
78
+ ...diagnostic,
79
+ assignedVariationId: variationId,
80
+ forced: true,
81
+ reason: "forced_variation",
82
+ }
83
+ : diagnostic,
84
+ );
85
+ runtimeListener?.();
86
+ });
87
+ resetMock = vi.fn((experimentId?: string) => {
88
+ if (!experimentId) {
89
+ snapshot.forceMap = null;
90
+ } else if (snapshot.forceMap) {
91
+ const next = { ...snapshot.forceMap };
92
+ delete next[experimentId];
93
+ snapshot.forceMap = Object.keys(next).length ? next : null;
94
+ }
95
+ runtimeListener?.();
96
+ });
97
+
98
+ vi.doMock("./index", () => ({
99
+ __getQaAssistantRuntimeSnapshot: () => snapshot,
100
+ __subscribeQaAssistantRuntime: (listener: () => void) => {
101
+ runtimeListener = listener;
102
+ return () => {
103
+ runtimeListener = null;
104
+ };
105
+ },
106
+ __forceQaAssistantVariation: forceMock,
107
+ __resetQaAssistantForce: resetMock,
108
+ }));
109
+ vi.doMock("./devtools", () => ({
110
+ subscribeDevtoolsEvents: (listener: typeof devtoolsListener) => {
111
+ devtoolsListener = listener;
112
+ return () => {
113
+ devtoolsListener = null;
114
+ };
115
+ },
116
+ }));
117
+ vi.doMock("./guards", () => ({
118
+ exitQaMode: vi.fn(),
119
+ }));
120
+
121
+ const { createQaAssistantStore } = await import("./qa-assistant-state");
122
+ return createQaAssistantStore();
123
+ }
124
+
125
+ afterEach(() => {
126
+ vi.restoreAllMocks();
127
+ vi.resetModules();
128
+ vi.unstubAllGlobals();
129
+ });
130
+
131
+ describe("createQaAssistantStore", () => {
132
+ it("maps condition diagnostics to pass, fail, skip, and error rows with raw JS", async () => {
133
+ const store = await loadStore();
134
+ snapshot.assignments = [];
135
+ snapshot.activationDiagnostics.experiments = [
136
+ {
137
+ experimentId: "exp_123456789",
138
+ experimentName: "Checkout CTA",
139
+ status: "failed",
140
+ reason: "targeting_mismatch",
141
+ forced: false,
142
+ gates: [],
143
+ targetingConditions: [
144
+ {
145
+ id: "url:0",
146
+ label: "URL include rule",
147
+ status: "passed",
148
+ expected: "/products/*",
149
+ actual: "https://shop.test/products/book",
150
+ source: "/products/*",
151
+ sourceType: "url",
152
+ },
153
+ {
154
+ id: "attribute:visitor_type:0",
155
+ label: "Attribute rule: visitor_type",
156
+ status: "failed",
157
+ reason: "attribute_mismatch",
158
+ expected: "returning",
159
+ actual: "new",
160
+ source: "visitor_type",
161
+ sourceType: "attribute",
162
+ },
163
+ {
164
+ id: "segment:vip:1",
165
+ label: "VIP segment: spend",
166
+ status: "skipped",
167
+ reason: "segment_and_failed",
168
+ source: "vip",
169
+ sourceType: "segment",
170
+ },
171
+ {
172
+ id: "page:js_rule",
173
+ label: "JS gate",
174
+ status: "failed",
175
+ reason: "js_error: bad trigger",
176
+ actual: false,
177
+ source: "throw new Error('bad trigger')",
178
+ sourceType: "js",
179
+ },
180
+ ],
181
+ },
182
+ ];
183
+ runtimeListener?.();
184
+
185
+ const experiment = store.getState().experiments[0]!;
186
+ expect(experiment.applied).toBe(false);
187
+ expect(experiment.conditions).toEqual([
188
+ expect.objectContaining({
189
+ id: "url:0",
190
+ state: "pass",
191
+ text: "URL include rule",
192
+ detail: "Expected: /products/* · Actual: https://shop.test/products/book",
193
+ rawCode: null,
194
+ }),
195
+ expect.objectContaining({
196
+ id: "attribute:visitor_type:0",
197
+ state: "fail",
198
+ text: "Attribute rule: visitor_type",
199
+ detail: "Expected: returning · Actual: new",
200
+ }),
201
+ expect.objectContaining({
202
+ id: "segment:vip:1",
203
+ state: "skip",
204
+ detail: "Skipped — segment_and_failed failed first",
205
+ }),
206
+ expect.objectContaining({
207
+ id: "page:js_rule",
208
+ state: "error",
209
+ text: "Custom JS trigger",
210
+ detail: "bad trigger",
211
+ rawCode: "throw new Error('bad trigger')",
212
+ }),
213
+ ]);
214
+ });
215
+
216
+ it("maps trigger, segment, and display-frequency pills without raw reason codes", async () => {
217
+ const store = await loadStore();
218
+ snapshot.assignments = [];
219
+ snapshot.activationDiagnostics.experiments[0] = {
220
+ ...snapshot.activationDiagnostics.experiments[0],
221
+ status: "failed",
222
+ reason: "outside_allocation",
223
+ targetingConditions: [
224
+ { id: "url:0", label: "Product pages", status: "passed", sourceType: "url" },
225
+ { id: "segment:vip:0", label: "VIP", status: "failed", sourceType: "segment" },
226
+ ],
227
+ };
228
+ runtimeListener?.();
229
+ expect(store.getState().experiments[0]).toMatchObject({
230
+ triggerPill: "accepted",
231
+ segmentPill: "rejected",
232
+ displayFrequencyPill: "outside traffic %",
233
+ });
234
+
235
+ for (const [reason, expected] of [
236
+ ["personalization_stopped_after_prior_match", "personalization stop"],
237
+ ["exclusion_group_overlap_lost", "excluded by overlap"],
238
+ ["exclusion_group_sticky_other", "excluded by overlap"],
239
+ ["status_paused", "not running"],
240
+ ] as const) {
241
+ snapshot.activationDiagnostics.experiments[0].reason = reason;
242
+ runtimeListener?.();
243
+ expect(store.getState().experiments[0].displayFrequencyPill).toBe(expected);
244
+ }
245
+ // A mere force REQUEST is not enough — the pill needs evidence the force
246
+ // took effect (forced diagnostic); a targeting-blocked force stays honest.
247
+ snapshot.forceMap = { exp_123456789: "var_b" };
248
+ runtimeListener?.();
249
+ expect(store.getState().experiments[0].displayFrequencyPill).not.toBe("forced");
250
+ snapshot.activationDiagnostics.experiments[0] = {
251
+ ...snapshot.activationDiagnostics.experiments[0],
252
+ forced: true,
253
+ reason: "forced_variation",
254
+ };
255
+ runtimeListener?.();
256
+ expect(store.getState().experiments[0].displayFrequencyPill).toBe("forced");
257
+ });
258
+
259
+ it("marks unevaluated campaigns and skipped display checks as not evaluated", async () => {
260
+ const store = await loadStore();
261
+ // Paused / no-variations: evaluation returns before targeting and traffic
262
+ // gates exist — configured rules were never checked.
263
+ snapshot.assignments = [];
264
+ snapshot.activationDiagnostics.experiments[0] = {
265
+ ...snapshot.activationDiagnostics.experiments[0],
266
+ status: "failed",
267
+ reason: "status_paused",
268
+ gates: [],
269
+ targetingConditions: [],
270
+ };
271
+ runtimeListener?.();
272
+ expect(store.getState().experiments[0]).toMatchObject({
273
+ triggerPill: "not evaluated",
274
+ segmentPill: "not evaluated",
275
+ displayFrequencyPill: "not running",
276
+ });
277
+
278
+ // Targeting rejection: the traffic gate never ran, so display frequency
279
+ // must not claim acceptance.
280
+ snapshot.activationDiagnostics.experiments[0] = {
281
+ ...snapshot.activationDiagnostics.experiments[0],
282
+ reason: "url_mismatch",
283
+ gates: [
284
+ { id: "page", label: "Page check", status: "failed", reason: "url_mismatch" },
285
+ { id: "audience", label: "Audience check", status: "skipped", reason: "page_mismatch" },
286
+ ],
287
+ };
288
+ runtimeListener?.();
289
+ expect(store.getState().experiments[0].displayFrequencyPill).toBe("not evaluated");
290
+
291
+ snapshot.activationDiagnostics.experiments[0].gates = [
292
+ { id: "page", label: "Page check", status: "passed", reason: "url_matched" },
293
+ { id: "traffic", label: "Traffic allocation", status: "passed", reason: "included" },
294
+ ];
295
+ snapshot.activationDiagnostics.experiments[0].reason = undefined;
296
+ runtimeListener?.();
297
+ expect(store.getState().experiments[0].displayFrequencyPill).toBe("accepted");
298
+ });
299
+
300
+ it("derives targeting pills from aggregate OR gates and preserves no-rules labels", async () => {
301
+ const store = await loadStore();
302
+ snapshot.activationDiagnostics.experiments[0] = {
303
+ ...snapshot.activationDiagnostics.experiments[0],
304
+ gates: [
305
+ { id: "page", label: "Page check", status: "passed", reason: "url_matched" },
306
+ { id: "audience", label: "Audience check", status: "passed", reason: "audience_matched" },
307
+ ],
308
+ targetingConditions: [
309
+ { id: "url:0", label: "First alternative", status: "failed", sourceType: "url" },
310
+ { id: "url:1", label: "Second alternative", status: "passed", sourceType: "url" },
311
+ { id: "segment:vip:0", label: "VIP", status: "failed", sourceType: "segment" },
312
+ { id: "segment:local:1", label: "Local", status: "passed", sourceType: "segment" },
313
+ ],
314
+ };
315
+ runtimeListener?.();
316
+ expect(store.getState().experiments[0]).toMatchObject({
317
+ triggerPill: "accepted",
318
+ segmentPill: "accepted",
319
+ });
320
+
321
+ snapshot.activationDiagnostics.experiments[0].gates = [
322
+ { id: "page", label: "Page check", status: "skipped", reason: "no_page_rules" },
323
+ { id: "audience", label: "Audience check", status: "skipped", reason: "no_audience_rules" },
324
+ ];
325
+ snapshot.activationDiagnostics.experiments[0].targetingConditions = [];
326
+ runtimeListener?.();
327
+ expect(store.getState().experiments[0]).toMatchObject({
328
+ triggerPill: "all pages",
329
+ segmentPill: "all visitors",
330
+ });
331
+ });
332
+
333
+ it("marks skipped targeting as not evaluated when an earlier gate prevented evaluation", async () => {
334
+ const store = await loadStore();
335
+ snapshot.activationDiagnostics.experiments[0] = {
336
+ ...snapshot.activationDiagnostics.experiments[0],
337
+ gates: [
338
+ { id: "page", label: "Page check", status: "failed", reason: "page_mismatch" },
339
+ { id: "audience", label: "Audience check", status: "skipped", reason: "page_mismatch" },
340
+ ],
341
+ targetingConditions: [
342
+ { id: "url:0", label: "Product pages", status: "failed", sourceType: "url" },
343
+ { id: "segment:vip:0", label: "VIP", status: "skipped", sourceType: "segment", reason: "page_mismatch" },
344
+ ],
345
+ };
346
+ runtimeListener?.();
347
+ expect(store.getState().experiments[0].segmentPill).toBe("not evaluated");
348
+
349
+ snapshot.activationDiagnostics.experiments[0].gates = [];
350
+ runtimeListener?.();
351
+ expect(store.getState().experiments[0].segmentPill).toBe("not evaluated");
352
+ });
353
+
354
+ it("shows page patterns alongside raw JS without relabeling plain JS", async () => {
355
+ const store = await loadStore();
356
+ const patterns = JSON.stringify([{ include: true, matchType: "simple", pattern: "/products/*" }]);
357
+ snapshot.activationDiagnostics.experiments[0].targetingConditions = [{
358
+ id: "page:products",
359
+ label: "Products",
360
+ status: "passed",
361
+ sourceType: "js",
362
+ source: "return true;",
363
+ expected: patterns,
364
+ actual: true,
365
+ }];
366
+ runtimeListener?.();
367
+ expect(store.getState().experiments[0].conditions[0]).toMatchObject({
368
+ detail: `Rules: ${patterns} · Actual: true`,
369
+ rawCode: "return true;",
370
+ });
371
+ });
372
+
373
+ it("marks the newer duplicate event within 1500ms", async () => {
374
+ const store = await loadStore();
375
+
376
+ devtoolsListener?.({
377
+ ts: 1000,
378
+ type: "track",
379
+ label: "goal",
380
+ detail: { goalId: "signup" },
381
+ });
382
+ devtoolsListener?.({
383
+ ts: 2200,
384
+ type: "track",
385
+ label: "goal",
386
+ detail: { goalId: "signup" },
387
+ });
388
+
389
+ 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 },
392
+ ]);
393
+ });
394
+
395
+ it("chooses unsupported, shopify, and order transaction models from runtime signals", async () => {
396
+ const store = await loadStore();
397
+ expect(store.getState().transactions).toEqual({ kind: "unsupported" });
398
+
399
+ vi.stubGlobal("window", { Shopify: {} });
400
+ vi.stubGlobal("fetch", vi.fn(async () => Response.json({
401
+ attributes: {
402
+ apex_assignments: JSON.stringify([{ e: "exp_123456789", v: "var_b" }]),
403
+ },
404
+ })) as unknown as typeof fetch);
405
+ snapshot.platform = "shopify";
406
+ snapshot.config.profile = { platform: "shopify", version: 1 };
407
+ runtimeListener?.();
408
+ await vi.waitFor(() => {
409
+ expect(store.getState().transactions).toEqual({
410
+ kind: "shopify",
411
+ cartStamped: true,
412
+ variationName: "Variant B",
413
+ stampedAt: null,
414
+ attribution: [{ experimentName: "Checkout CTA", variationName: "Variant B" }],
415
+ });
416
+ });
417
+
418
+ snapshot.orderIdentity = {
419
+ platform: "shopware",
420
+ platformOrderId: "order_123",
421
+ orderNumber: "1001",
422
+ };
423
+ runtimeListener?.();
424
+ expect(store.getState().transactions).toEqual({
425
+ kind: "order",
426
+ orderNumber: "1001",
427
+ attribution: [{ experimentName: "Checkout CTA", variationName: "Variant B" }],
428
+ serverReceipt: "unknown",
429
+ });
430
+ });
431
+
432
+ it("forces a variation through the runtime bridge and reflects forced state", async () => {
433
+ const store = await loadStore();
434
+
435
+ store.forceVariation("exp_123456789", "var_control");
436
+
437
+ expect(forceMock).toHaveBeenCalledWith("exp_123456789", "var_control");
438
+ expect(store.getState().forcedSummary).toBe("Checkout CTA: Control");
439
+ expect(store.getState().experiments[0]).toMatchObject({
440
+ variationId: "var_control",
441
+ forced: true,
442
+ subline: "Forced to Control",
443
+ });
444
+ });
445
+
446
+ it("keeps a targeting-rejected force request non-forced in the experiment model", async () => {
447
+ const store = await loadStore();
448
+ snapshot.forceMap = { exp_123456789: "var_b" };
449
+ snapshot.assignments = [];
450
+ snapshot.activationDiagnostics.experiments = [{
451
+ experimentId: "exp_123456789",
452
+ experimentName: "Checkout CTA",
453
+ status: "failed",
454
+ reason: "url_mismatch",
455
+ forced: false,
456
+ gates: [
457
+ { id: "page", label: "Page check", status: "failed", reason: "url_mismatch" },
458
+ ],
459
+ targetingConditions: [],
460
+ }];
461
+ runtimeListener?.();
462
+
463
+ expect(store.getState().forcedSummary).toBe("Checkout CTA: Variant B");
464
+ expect(store.getState().experiments[0]).toMatchObject({
465
+ applied: false,
466
+ forced: false,
467
+ triggerPill: "rejected",
468
+ displayFrequencyPill: "not evaluated",
469
+ });
470
+ });
471
+ });