@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,182 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildExposureSignalContext } from "./exposure-signals";
3
+
4
+ describe("buildExposureSignalContext", () => {
5
+ it("does not attach geo.country to exposure analytics", () => {
6
+ const context = buildExposureSignalContext({
7
+ attributes: { "geo.country": "DE" },
8
+ customSignals: [{ key: "geo.country", valueType: "string" }],
9
+ });
10
+ expect(context).toBeNull();
11
+ expect(JSON.stringify(context)).not.toContain("DE");
12
+ });
13
+ it("maps the built-in exposure allowlist", () => {
14
+ expect(buildExposureSignalContext({
15
+ attributes: {
16
+ "traffic.source": "paid",
17
+ "visitor.returning": true,
18
+ "shopify.page_type": "product",
19
+ "shopify.product_type": "Skincare",
20
+ "cart.item_count": 3,
21
+ "navigator.language": "en-US",
22
+ },
23
+ })).toEqual({
24
+ cart_item_count: 3,
25
+ language: "en-US",
26
+ page_type: "product",
27
+ product_type: "Skincare",
28
+ traffic_source: "paid",
29
+ visitor_returning: true,
30
+ });
31
+ });
32
+
33
+ it("excludes private and high-cardinality attributes", () => {
34
+ expect(buildExposureSignalContext({
35
+ attributes: {
36
+ "cookie.value": "session=secret",
37
+ "url.path": "/products/a",
38
+ "url.query": "?token=secret",
39
+ "utm.source": "newsletter",
40
+ "device.browser": "Chrome",
41
+ "geo.city": "Berlin",
42
+ referrer: "https://search.test",
43
+ "recently_viewed.handles": ["a", "b"],
44
+ "search.last_query": "sensitive search",
45
+ "shopify.customer_tag": ["vip"],
46
+ },
47
+ customSignals: [
48
+ { key: "cookie.value", valueType: "string" },
49
+ { key: "url.path", valueType: "string" },
50
+ { key: "recently_viewed.handles", valueType: "string_array" },
51
+ { key: "shopify.customer_tag", valueType: "string_array" },
52
+ ],
53
+ })).toBeNull();
54
+ });
55
+
56
+ it("excludes custom signals whose keys look like PII", () => {
57
+ expect(buildExposureSignalContext({
58
+ attributes: {
59
+ "customer.email": "jane@example.com",
60
+ "contact.phone": "+491701234567",
61
+ "shipping.address": "Some Street 1",
62
+ "billing.iban": "DE00 0000",
63
+ "auth.token": "tok_abc",
64
+ "visitor.dob": "1985-01-01",
65
+ "customer.name": "Jane Doe",
66
+ "quiz.answer": "hydration",
67
+ },
68
+ customSignals: [
69
+ { key: "customer.email", valueType: "string" },
70
+ { key: "contact.phone", valueType: "string" },
71
+ { key: "shipping.address", valueType: "string" },
72
+ { key: "billing.iban", valueType: "string" },
73
+ { key: "auth.token", valueType: "string" },
74
+ { key: "visitor.dob", valueType: "string" },
75
+ { key: "customer.name", valueType: "string" },
76
+ { key: "quiz.answer", valueType: "string" },
77
+ ],
78
+ })).toEqual({ quiz_answer: "hydration" });
79
+ });
80
+
81
+ it("detects PII tokens separated by camel-case boundaries", () => {
82
+ expect(buildExposureSignalContext({
83
+ attributes: {
84
+ customerEmail: "jane@example.com",
85
+ phoneNumber: "+491701234567",
86
+ sessionToken: "tok_abc",
87
+ quizAnswer: "hydration",
88
+ },
89
+ customSignals: [
90
+ { key: "customerEmail", valueType: "string" },
91
+ { key: "phoneNumber", valueType: "string" },
92
+ { key: "sessionToken", valueType: "string" },
93
+ { key: "quizAnswer", valueType: "string" },
94
+ ],
95
+ })).toEqual({ quizanswer: "hydration" });
96
+ });
97
+
98
+ it("includes scalar custom signals and filters unsupported value types", () => {
99
+ expect(buildExposureSignalContext({
100
+ attributes: {
101
+ "quiz.answer": " Dry skin ",
102
+ "loyalty.tier": "gold",
103
+ "profile.age": 42,
104
+ "customer.logged_in": false,
105
+ "json.payload": { nested: true },
106
+ "list.values": ["a", "b"],
107
+ "traffic.source.detail": "custom collision",
108
+ },
109
+ customSignals: [
110
+ { key: "quiz.answer", valueType: "string" },
111
+ { key: "loyalty.tier", valueType: "string" },
112
+ { key: "profile.age", valueType: "number" },
113
+ { key: "customer.logged_in", valueType: "boolean" },
114
+ { key: "json.payload", valueType: "json" },
115
+ { key: "list.values", valueType: "string_array" },
116
+ { key: "traffic.source.detail", valueType: "string" },
117
+ ],
118
+ })).toEqual({
119
+ customer_logged_in: false,
120
+ loyalty_tier: "gold",
121
+ profile_age: 42,
122
+ quiz_answer: "Dry skin",
123
+ });
124
+ });
125
+
126
+ it("truncates strings and drops empty or non-scalar values", () => {
127
+ const long = "x".repeat(150);
128
+ const context = buildExposureSignalContext({
129
+ attributes: {
130
+ "traffic.source": ` ${long} `,
131
+ "navigator.language": "",
132
+ "cart.item_count": Number.POSITIVE_INFINITY,
133
+ "shopify.page_type": ["product"],
134
+ "shopify.product_type": { value: "Serum" },
135
+ },
136
+ });
137
+
138
+ expect(context).toEqual({ traffic_source: "x".repeat(120) });
139
+ });
140
+
141
+ it("caps output at 24 keys by sorted key order", () => {
142
+ const attributes: Record<string, unknown> = {};
143
+ const customSignals = Array.from({ length: 30 }, (_, index) => {
144
+ const suffix = String(index).padStart(2, "0");
145
+ const key = `signal.${suffix}`;
146
+ attributes[key] = suffix;
147
+ return { key, valueType: "string" as const };
148
+ });
149
+
150
+ const context = buildExposureSignalContext({ attributes, customSignals });
151
+
152
+ expect(Object.keys(context ?? {})).toHaveLength(24);
153
+ expect(context).toHaveProperty("signal_23", "23");
154
+ expect(context).not.toHaveProperty("signal_24");
155
+ });
156
+
157
+ it("caps serialized payload size by dropping keys from the sorted end", () => {
158
+ const attributes: Record<string, unknown> = {};
159
+ const customSignals = Array.from({ length: 24 }, (_, index) => {
160
+ const suffix = String(index).padStart(2, "0");
161
+ const key = `oversized.${suffix}`;
162
+ attributes[key] = `${suffix}-${"x".repeat(118)}`;
163
+ return { key, valueType: "string" as const };
164
+ });
165
+
166
+ const context = buildExposureSignalContext({ attributes, customSignals });
167
+
168
+ expect(JSON.stringify(context).length).toBeLessThanOrEqual(2000);
169
+ expect(context).toHaveProperty("oversized_00");
170
+ expect(context).not.toHaveProperty("oversized_23");
171
+ });
172
+
173
+ it("returns null when no allowed values remain", () => {
174
+ expect(buildExposureSignalContext({
175
+ attributes: {
176
+ "traffic.source": " ",
177
+ "shopify.page_type": null,
178
+ "cart.item_count": Number.NaN,
179
+ },
180
+ })).toBeNull();
181
+ });
182
+ });
@@ -0,0 +1,110 @@
1
+ import type { RuntimeSignalDefinition } from "./types";
2
+ import { containsPiiToken } from "./pii-keys";
3
+
4
+ type ExposureSignalValue = string | number | boolean;
5
+
6
+ const BUILT_IN_SIGNALS: Array<[source: string, output: string]> = [
7
+ ["traffic.source", "traffic_source"],
8
+ ["visitor.returning", "visitor_returning"],
9
+ ["shopify.page_type", "page_type"],
10
+ ["shopify.product_type", "product_type"],
11
+ ["cart.item_count", "cart_item_count"],
12
+ ["navigator.language", "language"],
13
+ ];
14
+
15
+ const BUILT_IN_OUTPUT_KEYS = new Set(BUILT_IN_SIGNALS.map(([, output]) => output));
16
+ const MAX_KEYS = 24;
17
+ const MAX_JSON_CHARS = 2000;
18
+ const MAX_STRING_CHARS = 120;
19
+ const EXCLUDED_CUSTOM_KEYS = new Set([
20
+ "cookie.value",
21
+ "referrer",
22
+ "search.last_query",
23
+ "shopify.customer_tag",
24
+ ]);
25
+ const EXCLUDED_CUSTOM_PREFIXES = [
26
+ "url.",
27
+ "utm.",
28
+ "device.",
29
+ "geo.",
30
+ "recently_viewed.",
31
+ ];
32
+
33
+ // Custom signal keys are merchant-defined, so key-syntax validation alone
34
+ // cannot stop PII-shaped signals (customer.email, phone, …) from reaching
35
+ // analytics. Any key containing one of these tokens is never attached to
36
+ // exposure events, regardless of merchant configuration.
37
+ function normalizeValue(value: unknown): ExposureSignalValue | undefined {
38
+ if (value === undefined || value === null) return undefined;
39
+ if (typeof value === "boolean") return value;
40
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
41
+ if (typeof value === "string") {
42
+ const trimmed = value.trim();
43
+ return trimmed ? trimmed.slice(0, MAX_STRING_CHARS) : undefined;
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function outputKeyForCustomSignal(key: string): string {
49
+ return key.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
50
+ }
51
+
52
+ function collidesWithBuiltInPrefix(key: string): boolean {
53
+ if (!key) return true;
54
+ for (const builtInKey of BUILT_IN_OUTPUT_KEYS) {
55
+ if (key === builtInKey || key.startsWith(`${builtInKey}_`)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ function isExcludedCustomSignalKey(key: string): boolean {
61
+ const normalized = key.trim().toLowerCase();
62
+ if (EXCLUDED_CUSTOM_KEYS.has(normalized)) return true;
63
+ if (EXCLUDED_CUSTOM_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
64
+ return true;
65
+ }
66
+ return containsPiiToken(key);
67
+ }
68
+
69
+ function compactToLimits(values: Record<string, ExposureSignalValue>): Record<string, ExposureSignalValue> | null {
70
+ const keys = Object.keys(values).sort();
71
+ while (keys.length > MAX_KEYS) {
72
+ const key = keys.pop();
73
+ if (key) delete values[key];
74
+ }
75
+
76
+ while (keys.length > 0 && JSON.stringify(values).length > MAX_JSON_CHARS) {
77
+ const key = keys.pop();
78
+ if (key) delete values[key];
79
+ }
80
+
81
+ if (Object.keys(values).length === 0) return null;
82
+ return Object.fromEntries(
83
+ Object.keys(values).sort().map((key) => [key, values[key]]),
84
+ ) as Record<string, ExposureSignalValue>;
85
+ }
86
+
87
+ export function buildExposureSignalContext(input: {
88
+ attributes: Record<string, unknown>;
89
+ customSignals?: RuntimeSignalDefinition[];
90
+ }): Record<string, ExposureSignalValue> | null {
91
+ const values: Record<string, ExposureSignalValue> = {};
92
+
93
+ for (const [sourceKey, outputKey] of BUILT_IN_SIGNALS) {
94
+ const value = normalizeValue(input.attributes[sourceKey]);
95
+ if (value !== undefined) values[outputKey] = value;
96
+ }
97
+
98
+ for (const signal of input.customSignals ?? []) {
99
+ if (!signal.key) continue;
100
+ if (isExcludedCustomSignalKey(signal.key)) continue;
101
+ if (!["string", "number", "boolean"].includes(signal.valueType ?? "string")) continue;
102
+ const outputKey = outputKeyForCustomSignal(signal.key);
103
+ if (collidesWithBuiltInPrefix(outputKey)) continue;
104
+ if (Object.prototype.hasOwnProperty.call(values, outputKey)) continue;
105
+ const value = normalizeValue(input.attributes[signal.key]);
106
+ if (value !== undefined) values[outputKey] = value;
107
+ }
108
+
109
+ return compactToLimits(values);
110
+ }
@@ -0,0 +1,322 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ evaluateFlag,
4
+ evaluateFlagAssignment,
5
+ evaluateFlagValue,
6
+ defineFlagExperiment,
7
+ } from "./flags";
8
+ import type { Experiment } from "./types";
9
+
10
+ describe("defineFlagExperiment", () => {
11
+ it("creates exactly 2 variations", () => {
12
+ const flag = defineFlagExperiment({
13
+ slug: "test-flag",
14
+ name: "Test Flag",
15
+ rolloutPercent: 50,
16
+ });
17
+ expect(flag.variations).toHaveLength(2);
18
+ });
19
+
20
+ it("has one control variation with isControl: true", () => {
21
+ const flag = defineFlagExperiment({
22
+ slug: "test-flag",
23
+ name: "Test Flag",
24
+ rolloutPercent: 50,
25
+ });
26
+ const control = flag.variations.find((v) => v.id === "control");
27
+ expect(control).toBeDefined();
28
+ expect(control!.isControl).toBe(true);
29
+ });
30
+
31
+ it("has one enabled variation", () => {
32
+ const flag = defineFlagExperiment({
33
+ slug: "test-flag",
34
+ name: "Test Flag",
35
+ rolloutPercent: 50,
36
+ });
37
+ const enabled = flag.variations.find((v) => v.id === "enabled");
38
+ expect(enabled).toBeDefined();
39
+ expect(enabled!.isControl).toBe(false);
40
+ });
41
+
42
+ it("stores typed off and on values", () => {
43
+ const flag = defineFlagExperiment({
44
+ slug: "offer-copy",
45
+ name: "Offer Copy",
46
+ rolloutPercent: 100,
47
+ offValue: "standard",
48
+ onValue: { headline: "VIP offer" },
49
+ });
50
+
51
+ expect(flag.featureFlag?.key).toBe("offer-copy");
52
+ expect(flag.variations[0].value).toBe("standard");
53
+ expect(flag.variations[1].value).toEqual({ headline: "VIP offer" });
54
+ });
55
+
56
+ it("weights sum to 1", () => {
57
+ const flag = defineFlagExperiment({
58
+ slug: "test-flag",
59
+ name: "Test Flag",
60
+ rolloutPercent: 73,
61
+ });
62
+ const total = flag.variations.reduce((sum, v) => sum + v.weight, 0);
63
+ expect(total).toBeCloseTo(1, 10);
64
+ });
65
+
66
+ it("control has empty mutations", () => {
67
+ const flag = defineFlagExperiment({
68
+ slug: "test-flag",
69
+ name: "Test Flag",
70
+ rolloutPercent: 50,
71
+ });
72
+ const control = flag.variations.find((v) => v.id === "control");
73
+ expect(control!.mutations).toEqual([]);
74
+ });
75
+
76
+ it("rolloutPercent 50 produces weights [0.5, 0.5]", () => {
77
+ const flag = defineFlagExperiment({
78
+ slug: "test-flag",
79
+ name: "Test Flag",
80
+ rolloutPercent: 50,
81
+ });
82
+ expect(flag.variations[0].weight).toBeCloseTo(0.5);
83
+ expect(flag.variations[1].weight).toBeCloseTo(0.5);
84
+ });
85
+
86
+ it("rolloutPercent 0 produces weights [1, 0]", () => {
87
+ const flag = defineFlagExperiment({
88
+ slug: "test-flag",
89
+ name: "Test Flag",
90
+ rolloutPercent: 0,
91
+ });
92
+ expect(flag.variations[0].weight).toBe(1);
93
+ expect(flag.variations[1].weight).toBe(0);
94
+ });
95
+
96
+ it("rolloutPercent 100 produces weights [0, 1]", () => {
97
+ const flag = defineFlagExperiment({
98
+ slug: "test-flag",
99
+ name: "Test Flag",
100
+ rolloutPercent: 100,
101
+ });
102
+ expect(flag.variations[0].weight).toBe(0);
103
+ expect(flag.variations[1].weight).toBe(1);
104
+ });
105
+
106
+ it("clamps rolloutPercent > 100 to 100", () => {
107
+ const flag = defineFlagExperiment({
108
+ slug: "test-flag",
109
+ name: "Test Flag",
110
+ rolloutPercent: 150,
111
+ });
112
+ expect(flag.variations[0].weight).toBe(0);
113
+ expect(flag.variations[1].weight).toBe(1);
114
+ });
115
+
116
+ it("clamps rolloutPercent < 0 to 0", () => {
117
+ const flag = defineFlagExperiment({
118
+ slug: "test-flag",
119
+ name: "Test Flag",
120
+ rolloutPercent: -50,
121
+ });
122
+ expect(flag.variations[0].weight).toBe(1);
123
+ expect(flag.variations[1].weight).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe("evaluateFlag", () => {
128
+ it("returns true when rolloutPercent is 100 (all users enabled)", () => {
129
+ const flag = defineFlagExperiment({
130
+ slug: "test-flag",
131
+ name: "Test Flag",
132
+ rolloutPercent: 100,
133
+ });
134
+ // With weight [0, 1] all users land in "enabled"
135
+ const result = evaluateFlag("test-flag", [flag], { userId: "user-abc" });
136
+ expect(result).toBe(true);
137
+ });
138
+
139
+ it("returns false when rolloutPercent is 0 (all users control)", () => {
140
+ const flag = defineFlagExperiment({
141
+ slug: "test-flag",
142
+ name: "Test Flag",
143
+ rolloutPercent: 0,
144
+ });
145
+ // With weight [1, 0] all users land in "control"
146
+ const result = evaluateFlag("test-flag", [flag], { userId: "user-abc" });
147
+ expect(result).toBe(false);
148
+ });
149
+
150
+ it("returns false when flag is not found in experiments", () => {
151
+ const result = evaluateFlag("nonexistent", [], { userId: "user-abc" });
152
+ expect(result).toBe(false);
153
+ });
154
+
155
+ it("does not check experiment.status", () => {
156
+ // evaluateFlag does not filter by status; it only checks targeting and traffic
157
+ const flag = defineFlagExperiment({
158
+ slug: "test-flag",
159
+ name: "Test Flag",
160
+ rolloutPercent: 100,
161
+ });
162
+ // Manually set status to "paused" — evaluateFlag should still return true
163
+ const withStatus = { ...flag, status: "paused" } as Experiment;
164
+ const result = evaluateFlag("test-flag", [withStatus], {
165
+ userId: "user-abc",
166
+ });
167
+ expect(result).toBe(true);
168
+ });
169
+
170
+ it("returns false when targeting does not match", () => {
171
+ const flag = defineFlagExperiment({
172
+ slug: "test-flag",
173
+ name: "Test Flag",
174
+ rolloutPercent: 100,
175
+ });
176
+ // Add targeting that requires a specific URL
177
+ const targeted: Experiment = {
178
+ ...flag,
179
+ targeting: {
180
+ url: [{ type: "simple", pattern: "*.example.com/special/*" }],
181
+ },
182
+ };
183
+ const result = evaluateFlag("test-flag", [targeted], {
184
+ userId: "user-abc",
185
+ url: "https://other.test/page",
186
+ });
187
+ expect(result).toBe(false);
188
+ });
189
+ });
190
+
191
+ describe("evaluateFlagAssignment", () => {
192
+ it("returns a full Assignment object when flag is enabled", () => {
193
+ const flag = defineFlagExperiment({
194
+ slug: "test-flag",
195
+ name: "Test Flag",
196
+ rolloutPercent: 100,
197
+ });
198
+ const assignment = evaluateFlagAssignment("test-flag", [flag], {
199
+ userId: "user-abc",
200
+ });
201
+ expect(assignment).not.toBeNull();
202
+ expect(assignment!.experimentId).toBe("test-flag");
203
+ expect(assignment!.variationId).toBe("enabled");
204
+ expect(assignment!.isControl).toBe(false);
205
+ expect(assignment!.mutations).toEqual([]);
206
+ });
207
+
208
+ it("returns null when flag is not found", () => {
209
+ const assignment = evaluateFlagAssignment("nonexistent", [], {
210
+ userId: "user-abc",
211
+ });
212
+ expect(assignment).toBeNull();
213
+ });
214
+
215
+ it("returns assignment with isControl true when rolloutPercent is 0", () => {
216
+ const flag = defineFlagExperiment({
217
+ slug: "test-flag",
218
+ name: "Test Flag",
219
+ rolloutPercent: 0,
220
+ });
221
+ const assignment = evaluateFlagAssignment("test-flag", [flag], {
222
+ userId: "user-abc",
223
+ });
224
+ expect(assignment).not.toBeNull();
225
+ expect(assignment!.variationId).toBe("control");
226
+ expect(assignment!.isControl).toBe(true);
227
+ });
228
+
229
+ it("keeps variation buckets balanced inside partial traffic allocation", () => {
230
+ const flag: Experiment = {
231
+ ...defineFlagExperiment({
232
+ slug: "test-flag",
233
+ name: "Test Flag",
234
+ rolloutPercent: 50,
235
+ }),
236
+ trafficAllocation: 0.5,
237
+ };
238
+
239
+ const assignments = Array.from({ length: 2000 }, (_, index) =>
240
+ evaluateFlagAssignment("test-flag", [flag], { userId: `user-${index}` }),
241
+ ).filter((assignment): assignment is NonNullable<typeof assignment> =>
242
+ assignment !== null,
243
+ );
244
+
245
+ const control = assignments.filter((assignment) => assignment.variationId === "control").length;
246
+ const enabled = assignments.filter((assignment) => assignment.variationId === "enabled").length;
247
+
248
+ expect(assignments.length).toBeGreaterThan(900);
249
+ expect(assignments.length).toBeLessThan(1100);
250
+ expect(control / assignments.length).toBeGreaterThan(0.45);
251
+ expect(enabled / assignments.length).toBeGreaterThan(0.45);
252
+ });
253
+
254
+ it("returns null when targeting does not match", () => {
255
+ const flag = defineFlagExperiment({
256
+ slug: "test-flag",
257
+ name: "Test Flag",
258
+ rolloutPercent: 100,
259
+ });
260
+ const targeted: Experiment = {
261
+ ...flag,
262
+ targeting: {
263
+ url: [{ type: "simple", pattern: "*.example.com/special/*" }],
264
+ },
265
+ };
266
+ const assignment = evaluateFlagAssignment("test-flag", [targeted], {
267
+ userId: "user-abc",
268
+ url: "https://other.test/page",
269
+ });
270
+ expect(assignment).toBeNull();
271
+ });
272
+ });
273
+
274
+ describe("evaluateFlagValue", () => {
275
+ it("returns the typed on value for enabled visitors", () => {
276
+ const flag = defineFlagExperiment({
277
+ slug: "offer-copy",
278
+ name: "Offer Copy",
279
+ rolloutPercent: 100,
280
+ offValue: "control",
281
+ onValue: "personalized",
282
+ });
283
+
284
+ expect(
285
+ evaluateFlagValue("offer-copy", [flag], { userId: "user-abc" }, "fallback"),
286
+ ).toBe("personalized");
287
+ });
288
+
289
+ it("returns the fallback when targeting does not match", () => {
290
+ const flag = defineFlagExperiment({
291
+ slug: "vip-offer",
292
+ name: "VIP Offer",
293
+ rolloutPercent: 100,
294
+ onValue: { badge: "VIP" },
295
+ targeting: {
296
+ segmentRules: [
297
+ {
298
+ id: "11111111-1111-4111-8111-111111111111",
299
+ rules: {
300
+ version: 1,
301
+ operator: "and",
302
+ conditions: [
303
+ {
304
+ signal: "shopify.customer_tag",
305
+ operator: "contains",
306
+ value: "VIP",
307
+ },
308
+ ],
309
+ },
310
+ },
311
+ ],
312
+ },
313
+ });
314
+
315
+ expect(
316
+ evaluateFlagValue("vip-offer", [flag], {
317
+ userId: "user-abc",
318
+ attributes: { "shopify.customer_tag": ["Regular"] },
319
+ }, "fallback"),
320
+ ).toBe("fallback");
321
+ });
322
+ });