@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.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/_headers +3 -0
- package/dist/bundle-report.json +564 -0
- package/dist/drip.debug.js +2 -0
- package/dist/drip.debug.js.map +1 -0
- package/dist/drip.heatmap.js +2 -0
- package/dist/drip.heatmap.js.map +1 -0
- package/dist/drip.js +2 -0
- package/dist/drip.js.map +1 -0
- package/dist/drip.live.js +2 -0
- package/dist/drip.live.js.map +1 -0
- package/dist/editor.js +480 -0
- package/dist/editor.js.map +7 -0
- package/dist/index.mjs +645 -0
- package/dist/index.mjs.map +7 -0
- package/dist/metafiles/drip.debug.meta.json +973 -0
- package/dist/metafiles/drip.heatmap.meta.json +918 -0
- package/dist/metafiles/drip.live.meta.json +918 -0
- package/dist/metafiles/drip.meta.json +973 -0
- package/dist/metafiles/editor.meta.json +321 -0
- package/dist/metafiles/index.meta.json +1039 -0
- package/dist/metafiles/index.mjs.meta.json +78 -0
- package/dist/react/index.js +16973 -0
- package/dist/react/index.js.map +7 -0
- package/dist/sri.json +27 -0
- package/dist/types/analytics.d.ts +15 -0
- package/dist/types/antiflicker.d.ts +14 -0
- package/dist/types/assignment-store.d.ts +30 -0
- package/dist/types/audit.d.ts +9 -0
- package/dist/types/bucket.d.ts +2 -0
- package/dist/types/bucketing-cleanup.d.ts +5 -0
- package/dist/types/commerce-gift.d.ts +41 -0
- package/dist/types/commerce-price.d.ts +22 -0
- package/dist/types/commerce-shipping.d.ts +28 -0
- package/dist/types/css.d.ts +5 -0
- package/dist/types/cwv.d.ts +10 -0
- package/dist/types/data-layer-normalize.d.ts +1 -0
- package/dist/types/debug.d.ts +23 -0
- package/dist/types/define.d.ts +87 -0
- package/dist/types/devtools.d.ts +32 -0
- package/dist/types/dom-trigger.d.ts +3 -0
- package/dist/types/editor/api.d.ts +37 -0
- package/dist/types/editor/artifacts.d.ts +15 -0
- package/dist/types/editor/command-runtime.d.ts +48 -0
- package/dist/types/editor/controller.d.ts +97 -0
- package/dist/types/editor/device-preview.d.ts +23 -0
- package/dist/types/editor/index.d.ts +9 -0
- package/dist/types/editor/operator.d.ts +39 -0
- package/dist/types/editor/picker.d.ts +23 -0
- package/dist/types/editor/selector.d.ts +2 -0
- package/dist/types/editor/types.d.ts +55 -0
- package/dist/types/editor/view-kit.d.ts +33 -0
- package/dist/types/editor/view.d.ts +5 -0
- package/dist/types/editor-bootstrap.d.ts +37 -0
- package/dist/types/exclusion-groups.d.ts +59 -0
- package/dist/types/exposure-signals.d.ts +7 -0
- package/dist/types/flags.d.ts +65 -0
- package/dist/types/goals.d.ts +16 -0
- package/dist/types/guards.d.ts +39 -0
- package/dist/types/handover.d.ts +10 -0
- package/dist/types/hash.d.ts +19 -0
- package/dist/types/heatmap.d.ts +8 -0
- package/dist/types/hideflicker.d.ts +15 -0
- package/dist/types/hydration.d.ts +11 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/js.d.ts +6 -0
- package/dist/types/module.d.ts +4 -0
- package/dist/types/money.d.ts +14 -0
- package/dist/types/mutate.d.ts +34 -0
- package/dist/types/observer-mute.d.ts +3 -0
- package/dist/types/pii-keys.d.ts +1 -0
- package/dist/types/prerequisites.d.ts +12 -0
- package/dist/types/qa-assistant-state.d.ts +68 -0
- package/dist/types/qa-assistant-view.d.ts +6 -0
- package/dist/types/qa-assistant.d.ts +9 -0
- package/dist/types/qa-toolbar.d.ts +19 -0
- package/dist/types/react/index.d.ts +84 -0
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/router.d.ts +9 -0
- package/dist/types/runtime-tools.d.ts +50 -0
- package/dist/types/runtime.d.ts +22 -0
- package/dist/types/safe-regex.d.ts +6 -0
- package/dist/types/sanitize.d.ts +5 -0
- package/dist/types/semantic.d.ts +7 -0
- package/dist/types/shopify-cart-events.d.ts +7 -0
- package/dist/types/shopify-cart.d.ts +62 -0
- package/dist/types/signals.d.ts +31 -0
- package/dist/types/surface-signals.d.ts +6 -0
- package/dist/types/targeting-diagnostics.d.ts +5 -0
- package/dist/types/targeting.d.ts +50 -0
- package/dist/types/test-fixtures/commerce.d.ts +12 -0
- package/dist/types/track.d.ts +79 -0
- package/dist/types/types.d.ts +859 -0
- package/dist/types/wait.d.ts +7 -0
- package/package.json +74 -0
- package/src/analytics.ts +146 -0
- package/src/antiflicker.test.ts +127 -0
- package/src/antiflicker.ts +147 -0
- package/src/assignment-store.test.ts +171 -0
- package/src/assignment-store.ts +208 -0
- package/src/audit.test.ts +17 -0
- package/src/audit.ts +362 -0
- package/src/bucket.test.ts +65 -0
- package/src/bucket.ts +18 -0
- package/src/bucketing-cleanup.test.ts +30 -0
- package/src/bucketing-cleanup.ts +72 -0
- package/src/bucketing-parity.test.ts +41 -0
- package/src/commerce-analytics-lifecycle.test.ts +1416 -0
- package/src/commerce-config-lifecycle.test.ts +274 -0
- package/src/commerce-epoch-lifecycle.test.ts +62 -0
- package/src/commerce-gift.test.ts +1293 -0
- package/src/commerce-gift.ts +979 -0
- package/src/commerce-offers-display.test.ts +154 -0
- package/src/commerce-price.test.ts +1145 -0
- package/src/commerce-price.ts +941 -0
- package/src/commerce-revocation-lifecycle.test.ts +78 -0
- package/src/commerce-shipping.test.ts +630 -0
- package/src/commerce-shipping.ts +447 -0
- package/src/config-fetch.test.ts +325 -0
- package/src/css.ts +54 -0
- package/src/cwv.test.ts +95 -0
- package/src/cwv.ts +129 -0
- package/src/data-layer-normalize.ts +21 -0
- package/src/debug.test.ts +83 -0
- package/src/debug.ts +138 -0
- package/src/define.test.ts +327 -0
- package/src/define.ts +208 -0
- package/src/devtools.ts +582 -0
- package/src/dom-trigger.test.ts +70 -0
- package/src/dom-trigger.ts +55 -0
- package/src/editor/api.test.ts +154 -0
- package/src/editor/api.ts +185 -0
- package/src/editor/artifacts.test.ts +57 -0
- package/src/editor/artifacts.ts +115 -0
- package/src/editor/command-runtime.test.ts +225 -0
- package/src/editor/command-runtime.ts +572 -0
- package/src/editor/controller.test.ts +371 -0
- package/src/editor/controller.ts +596 -0
- package/src/editor/device-preview.test.ts +60 -0
- package/src/editor/device-preview.ts +120 -0
- package/src/editor/index.test.ts +161 -0
- package/src/editor/index.ts +169 -0
- package/src/editor/operator.test.ts +148 -0
- package/src/editor/operator.ts +256 -0
- package/src/editor/picker.test.ts +58 -0
- package/src/editor/picker.ts +152 -0
- package/src/editor/selector.test.ts +44 -0
- package/src/editor/selector.ts +110 -0
- package/src/editor/types.ts +92 -0
- package/src/editor/view-kit.ts +162 -0
- package/src/editor/view.test.ts +535 -0
- package/src/editor/view.ts +1395 -0
- package/src/editor-bootstrap.test.ts +116 -0
- package/src/editor-bootstrap.ts +213 -0
- package/src/event-schema-contract.test.ts +35 -0
- package/src/exclusion-groups.test.ts +383 -0
- package/src/exclusion-groups.ts +270 -0
- package/src/exposure-signals.test.ts +182 -0
- package/src/exposure-signals.ts +110 -0
- package/src/flags.test.ts +322 -0
- package/src/flags.ts +201 -0
- package/src/goals.test.ts +42 -0
- package/src/goals.ts +200 -0
- package/src/guards.test.ts +249 -0
- package/src/guards.ts +250 -0
- package/src/handover-cache-order.test.ts +32 -0
- package/src/handover.test.ts +349 -0
- package/src/handover.ts +309 -0
- package/src/hash.ts +214 -0
- package/src/heatmap.test.ts +305 -0
- package/src/heatmap.ts +373 -0
- package/src/hideflicker.ts +21 -0
- package/src/hydration.test.ts +252 -0
- package/src/hydration.ts +203 -0
- package/src/index.ts +7513 -0
- package/src/init-empty.test.ts +5572 -0
- package/src/js.test.ts +26 -0
- package/src/js.ts +61 -0
- package/src/module.ts +50 -0
- package/src/money.test.ts +35 -0
- package/src/money.ts +53 -0
- package/src/mutate.test.ts +304 -0
- package/src/mutate.ts +469 -0
- package/src/observer-mute.test.ts +88 -0
- package/src/observer-mute.ts +45 -0
- package/src/page-trigger.test.ts +84 -0
- package/src/personalization.test.ts +68 -0
- package/src/pii-keys.ts +17 -0
- package/src/prerequisites.test.ts +39 -0
- package/src/prerequisites.ts +94 -0
- package/src/qa-assistant-state.test.ts +471 -0
- package/src/qa-assistant-state.ts +505 -0
- package/src/qa-assistant-view.test.ts +185 -0
- package/src/qa-assistant-view.ts +768 -0
- package/src/qa-assistant.ts +40 -0
- package/src/qa-toolbar.ts +444 -0
- package/src/react/index.test.tsx +177 -0
- package/src/react/index.tsx +252 -0
- package/src/reassert-sentinel.test.ts +314 -0
- package/src/reassert-sentinel.ts +191 -0
- package/src/router.test.ts +78 -0
- package/src/router.ts +92 -0
- package/src/runtime-tools.test.ts +112 -0
- package/src/runtime-tools.ts +403 -0
- package/src/runtime.test.ts +217 -0
- package/src/runtime.ts +268 -0
- package/src/safe-regex.ts +35 -0
- package/src/sanitize.ts +34 -0
- package/src/semantic.ts +245 -0
- package/src/shopify-cart-events.test.ts +76 -0
- package/src/shopify-cart-events.ts +115 -0
- package/src/shopify-cart.test.ts +2823 -0
- package/src/shopify-cart.ts +1256 -0
- package/src/signals.ts +226 -0
- package/src/surface-signals.test.ts +83 -0
- package/src/surface-signals.ts +165 -0
- package/src/targeting-diagnostics.ts +560 -0
- package/src/targeting.detailed.test.ts +443 -0
- package/src/targeting.test.ts +1054 -0
- package/src/targeting.ts +1024 -0
- package/src/test-fixtures/commerce.ts +65 -0
- package/src/track.test.ts +817 -0
- package/src/track.ts +519 -0
- package/src/types.ts +991 -0
- package/src/wait.test.ts +96 -0
- package/src/wait.ts +238 -0
package/src/flags.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flags — thin wrapper over experiment assignments.
|
|
3
|
+
*
|
|
4
|
+
* A feature flag is simply a two-variation experiment where:
|
|
5
|
+
* - control = flag OFF
|
|
6
|
+
* - variant = flag ON
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { evaluateFlag, defineFlagExperiment } from "@drip-apex/sdk/flags";
|
|
10
|
+
*
|
|
11
|
+
* const darkMode = defineFlagExperiment({
|
|
12
|
+
* slug: "dark-mode-rollout",
|
|
13
|
+
* name: "Dark Mode Rollout",
|
|
14
|
+
* rolloutPercent: 25,
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* const isEnabled = evaluateFlag("dark-mode-rollout", [darkMode], { userId: "user-123" });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { chooseVariation, inTrafficAllocation } from "./bucket";
|
|
21
|
+
import { defineExperiment } from "./define";
|
|
22
|
+
import type { Experiment, Assignment } from "./types";
|
|
23
|
+
import { getUserId, hashToFloat, variationHashToFloat } from "./hash";
|
|
24
|
+
import { buildTargetingAttributes, matchesTargeting } from "./targeting";
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// evaluateFlag — check if a flag is ON for a given user
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export interface FlagContext {
|
|
31
|
+
url?: string;
|
|
32
|
+
attributes?: Record<string, unknown>;
|
|
33
|
+
userId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate whether a feature flag is enabled.
|
|
38
|
+
*
|
|
39
|
+
* @param flagId - The experiment slug/id of the flag experiment.
|
|
40
|
+
* @param experiments - Array of experiment configs to search (from init or defineExperiment).
|
|
41
|
+
* @param context - Optional user context for targeting/bucketing.
|
|
42
|
+
* @returns `true` if the user is bucketed into a non-control variation.
|
|
43
|
+
*/
|
|
44
|
+
export function evaluateFlag(
|
|
45
|
+
flagId: string,
|
|
46
|
+
experiments: Experiment[],
|
|
47
|
+
context?: FlagContext,
|
|
48
|
+
): boolean {
|
|
49
|
+
const experiment = experiments.find((e) => e.id === flagId);
|
|
50
|
+
if (!experiment) return false;
|
|
51
|
+
|
|
52
|
+
const url = context?.url ?? (typeof location !== "undefined" ? location.href : "");
|
|
53
|
+
const attributes = buildTargetingAttributes(context?.attributes ?? {}, url);
|
|
54
|
+
|
|
55
|
+
if (!matchesTargeting(experiment.targeting, { url, attributes })) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const userId = getUserId(context?.userId);
|
|
60
|
+
const hashValue = hashToFloat(experiment.id, userId);
|
|
61
|
+
|
|
62
|
+
if (!inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allocation = experiment.trafficAllocation ?? 1;
|
|
67
|
+
const variationHashValue =
|
|
68
|
+
allocation >= 1 ? hashValue : variationHashToFloat(experiment.id, userId);
|
|
69
|
+
const weights = experiment.variations.map((v) => v.weight);
|
|
70
|
+
const index = chooseVariation(variationHashValue, weights);
|
|
71
|
+
if (index < 0 || index >= experiment.variations.length) return false;
|
|
72
|
+
|
|
73
|
+
const variation = experiment.variations[index];
|
|
74
|
+
return !variation.isControl;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Evaluate a flag and return the full assignment (or null if flag is off / not found).
|
|
79
|
+
*/
|
|
80
|
+
export function evaluateFlagAssignment(
|
|
81
|
+
flagId: string,
|
|
82
|
+
experiments: Experiment[],
|
|
83
|
+
context?: FlagContext,
|
|
84
|
+
): Assignment | null {
|
|
85
|
+
const experiment = experiments.find((e) => e.id === flagId);
|
|
86
|
+
if (!experiment) return null;
|
|
87
|
+
|
|
88
|
+
const url = context?.url ?? (typeof location !== "undefined" ? location.href : "");
|
|
89
|
+
const attributes = buildTargetingAttributes(context?.attributes ?? {}, url);
|
|
90
|
+
|
|
91
|
+
if (!matchesTargeting(experiment.targeting, { url, attributes })) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const userId = getUserId(context?.userId);
|
|
96
|
+
const hashValue = hashToFloat(experiment.id, userId);
|
|
97
|
+
|
|
98
|
+
if (!inTrafficAllocation(hashValue, experiment.trafficAllocation ?? 1)) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const allocation = experiment.trafficAllocation ?? 1;
|
|
103
|
+
const variationHashValue =
|
|
104
|
+
allocation >= 1 ? hashValue : variationHashToFloat(experiment.id, userId);
|
|
105
|
+
const weights = experiment.variations.map((v) => v.weight);
|
|
106
|
+
const index = chooseVariation(variationHashValue, weights);
|
|
107
|
+
if (index < 0 || index >= experiment.variations.length) return null;
|
|
108
|
+
|
|
109
|
+
const variation = experiment.variations[index];
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
experimentId: experiment.id,
|
|
113
|
+
experimentName: undefined,
|
|
114
|
+
variationId: variation.id,
|
|
115
|
+
index,
|
|
116
|
+
mutations: variation.mutations ?? [],
|
|
117
|
+
value: variation.value,
|
|
118
|
+
featureFlag: experiment.featureFlag,
|
|
119
|
+
kind: experiment.kind ?? (experiment.featureFlag ? "feature_flag" : "experiment"),
|
|
120
|
+
css: variation.css,
|
|
121
|
+
js: variation.js,
|
|
122
|
+
resetJs: variation.resetJs,
|
|
123
|
+
externalCssUrl: variation.externalCssUrl,
|
|
124
|
+
externalJsUrl: variation.externalJsUrl,
|
|
125
|
+
harnessSession: variation.harnessSession,
|
|
126
|
+
isControl: Boolean(variation.isControl),
|
|
127
|
+
hashValue: userId,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Evaluate a typed feature flag value.
|
|
133
|
+
*
|
|
134
|
+
* Returns the assigned variation value for boolean, string, number, or JSON
|
|
135
|
+
* flags. If the visitor is not eligible, the flag is missing, or no assignment
|
|
136
|
+
* can be made, the fallback is returned.
|
|
137
|
+
*/
|
|
138
|
+
export function evaluateFlagValue<T = unknown>(
|
|
139
|
+
flagId: string,
|
|
140
|
+
experiments: Experiment[],
|
|
141
|
+
context?: FlagContext,
|
|
142
|
+
fallback?: T,
|
|
143
|
+
): T | unknown {
|
|
144
|
+
const assignment = evaluateFlagAssignment(flagId, experiments, context);
|
|
145
|
+
if (!assignment) return fallback;
|
|
146
|
+
return assignment.value === undefined ? fallback : assignment.value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// defineFlagExperiment — convenience for creating flag-style experiments
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
export interface FlagDefinition {
|
|
154
|
+
/** Unique slug for the flag. */
|
|
155
|
+
slug: string;
|
|
156
|
+
/** Human-readable name. */
|
|
157
|
+
name: string;
|
|
158
|
+
/** Percentage of traffic to enable the flag for (0–100). Default: 0 (off). */
|
|
159
|
+
rolloutPercent?: number;
|
|
160
|
+
/** Targeting rules. */
|
|
161
|
+
targeting?: Experiment["targeting"];
|
|
162
|
+
/** Value returned when the flag is disabled. Default: false. */
|
|
163
|
+
offValue?: unknown;
|
|
164
|
+
/** Value returned when the flag is enabled. Default: true. */
|
|
165
|
+
onValue?: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create a flag-style experiment with two variations: control (OFF) and enabled (ON).
|
|
170
|
+
*/
|
|
171
|
+
export function defineFlagExperiment(def: FlagDefinition): Experiment & { slug: string } {
|
|
172
|
+
const rollout = Math.max(0, Math.min(100, def.rolloutPercent ?? 0)) / 100;
|
|
173
|
+
|
|
174
|
+
const flag = defineExperiment({
|
|
175
|
+
slug: def.slug,
|
|
176
|
+
name: def.name,
|
|
177
|
+
trafficAllocation: 1,
|
|
178
|
+
targeting: def.targeting,
|
|
179
|
+
variations: [
|
|
180
|
+
{
|
|
181
|
+
id: "control",
|
|
182
|
+
weight: 1 - rollout,
|
|
183
|
+
mutations: [],
|
|
184
|
+
isControl: true,
|
|
185
|
+
value: def.offValue ?? false,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: "enabled",
|
|
189
|
+
weight: rollout,
|
|
190
|
+
mutations: [],
|
|
191
|
+
value: def.onValue ?? true,
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
...flag,
|
|
198
|
+
variations: flag.variations.map((variation) => ({ ...variation })),
|
|
199
|
+
featureFlag: { key: def.slug },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { setupGoals } from "./goals";
|
|
4
|
+
import { setJsPolicy } from "./js";
|
|
5
|
+
|
|
6
|
+
const assignment = { experimentId: "exp", variationId: "var" } as const;
|
|
7
|
+
const experiment = {
|
|
8
|
+
id: "exp",
|
|
9
|
+
goals: [{
|
|
10
|
+
id: "goal", type: "click" as const, selector: "button",
|
|
11
|
+
capture: [
|
|
12
|
+
{ property: "customer_email", source: "attribute" as const, attribute: "data-email" },
|
|
13
|
+
{ property: "product_id", source: "dataset" as const, attribute: "data-product-id" },
|
|
14
|
+
],
|
|
15
|
+
}],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe("goal capture policy", () => {
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
setupGoals([], [], vi.fn());
|
|
21
|
+
setJsPolicy("hybrid");
|
|
22
|
+
document.body.innerHTML = "";
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("drops PII-shaped captures only under strict policy", () => {
|
|
26
|
+
document.body.innerHTML = '<button data-email="person@example.com" data-product-id="123">Buy</button>';
|
|
27
|
+
const button = document.querySelector("button")!;
|
|
28
|
+
const strictCallback = vi.fn();
|
|
29
|
+
setJsPolicy("strict");
|
|
30
|
+
setupGoals([assignment], [experiment], strictCallback);
|
|
31
|
+
button.click();
|
|
32
|
+
expect(strictCallback).toHaveBeenCalledWith("goal", assignment, { product_id: "123" });
|
|
33
|
+
|
|
34
|
+
const legacyCallback = vi.fn();
|
|
35
|
+
setJsPolicy("hybrid");
|
|
36
|
+
setupGoals([assignment], [experiment], legacyCallback);
|
|
37
|
+
button.click();
|
|
38
|
+
expect(legacyCallback).toHaveBeenCalledWith("goal", assignment, {
|
|
39
|
+
customer_email: "person@example.com", product_id: "123",
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/goals.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Goal, Assignment, CaptureRule } from "./types";
|
|
2
|
+
import { safeRegexTest } from "./safe-regex";
|
|
3
|
+
import { containsPiiToken } from "./pii-keys";
|
|
4
|
+
import { isStrictJsPolicy } from "./js";
|
|
5
|
+
|
|
6
|
+
type GoalCallback = (
|
|
7
|
+
goalId: string,
|
|
8
|
+
assignment: Assignment,
|
|
9
|
+
data?: Record<string, unknown>,
|
|
10
|
+
) => void;
|
|
11
|
+
|
|
12
|
+
const STORAGE_KEY = "drip_goals";
|
|
13
|
+
let onGoal: GoalCallback | null = null;
|
|
14
|
+
let clickListener: ((e: Event) => void) | null = null;
|
|
15
|
+
let activeClickGoals: { goal: Goal; assignment: Assignment }[] = [];
|
|
16
|
+
|
|
17
|
+
function getFiredGoals(): Record<string, string[]> {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function markFired(goalId: string, variationId: string): void {
|
|
26
|
+
const fired = getFiredGoals();
|
|
27
|
+
const list = fired[goalId] || [];
|
|
28
|
+
if (!list.includes(variationId)) {
|
|
29
|
+
list.push(variationId);
|
|
30
|
+
fired[goalId] = list;
|
|
31
|
+
try {
|
|
32
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(fired));
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function alreadyFired(goalId: string, variationId: string): boolean {
|
|
38
|
+
const fired = getFiredGoals();
|
|
39
|
+
return (fired[goalId] || []).includes(variationId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fireGoal(
|
|
43
|
+
goal: Goal,
|
|
44
|
+
assignment: Assignment,
|
|
45
|
+
data?: Record<string, unknown>,
|
|
46
|
+
): void {
|
|
47
|
+
if (goal.countOnce && alreadyFired(goal.id, assignment.variationId)) return;
|
|
48
|
+
if (goal.countOnce) markFired(goal.id, assignment.variationId);
|
|
49
|
+
if (onGoal) onGoal(goal.id, assignment, data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Extract captured values from a matched element based on capture rules.
|
|
54
|
+
* Values are sent as event properties alongside the goal event.
|
|
55
|
+
*/
|
|
56
|
+
function extractCapturedValues(
|
|
57
|
+
el: Element,
|
|
58
|
+
capture?: CaptureRule[],
|
|
59
|
+
): Record<string, unknown> {
|
|
60
|
+
if (!capture?.length) return {};
|
|
61
|
+
const data: Record<string, unknown> = {};
|
|
62
|
+
for (const rule of capture) {
|
|
63
|
+
if (isStrictJsPolicy() && (containsPiiToken(rule.property) ||
|
|
64
|
+
(rule.attribute && containsPiiToken(rule.attribute)))) continue;
|
|
65
|
+
if (rule.source === "attribute" && rule.attribute) {
|
|
66
|
+
data[rule.property] = el.getAttribute(rule.attribute);
|
|
67
|
+
} else if (rule.source === "textContent") {
|
|
68
|
+
data[rule.property] = (el.textContent || "").trim().slice(0, 500);
|
|
69
|
+
} else if (rule.source === "dataset" && rule.attribute) {
|
|
70
|
+
// Convert "data-product-id" → "productId" for dataset access
|
|
71
|
+
const key = rule.attribute
|
|
72
|
+
.replace(/^data-/, "")
|
|
73
|
+
.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
74
|
+
data[rule.property] = (el as HTMLElement).dataset?.[key] ?? null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function textMatches(el: Element, text: string): boolean {
|
|
81
|
+
return (el.textContent || "")
|
|
82
|
+
.trim()
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.includes(text.trim().toLowerCase());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findByText(start: Element, text: string): Element | null {
|
|
88
|
+
let el: Element | null = start;
|
|
89
|
+
const needle = text.trim().toLowerCase();
|
|
90
|
+
while (el && el !== document.documentElement) {
|
|
91
|
+
if ((el.textContent || "").trim().toLowerCase().includes(needle)) return el;
|
|
92
|
+
el = el.parentElement;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function handleClick(e: Event): void {
|
|
98
|
+
const target = e.target as Element;
|
|
99
|
+
if (!target?.closest) return;
|
|
100
|
+
for (const entry of activeClickGoals) {
|
|
101
|
+
const { selector, textMatch } = entry.goal;
|
|
102
|
+
if (!selector && !textMatch) continue;
|
|
103
|
+
|
|
104
|
+
let matched: Element | null = null;
|
|
105
|
+
|
|
106
|
+
if (selector) {
|
|
107
|
+
matched = target.closest(selector);
|
|
108
|
+
if (!matched) continue;
|
|
109
|
+
// If textMatch is also set, verify the text
|
|
110
|
+
if (textMatch && !textMatches(matched, textMatch)) continue;
|
|
111
|
+
} else if (textMatch) {
|
|
112
|
+
// No CSS selector — walk up from target checking text
|
|
113
|
+
matched = findByText(target, textMatch);
|
|
114
|
+
if (!matched) continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const captured = extractCapturedValues(matched!, entry.goal.capture);
|
|
118
|
+
fireGoal(entry.goal, entry.assignment, captured);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setupGoals(
|
|
123
|
+
assignments: Assignment[],
|
|
124
|
+
experiments: { id: string; goals?: Goal[] }[],
|
|
125
|
+
callback: GoalCallback,
|
|
126
|
+
): void {
|
|
127
|
+
onGoal = callback;
|
|
128
|
+
activeClickGoals = [];
|
|
129
|
+
|
|
130
|
+
if (clickListener) {
|
|
131
|
+
document.removeEventListener("click", clickListener, true);
|
|
132
|
+
clickListener = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const expMap = new Map(experiments.map((e) => [e.id, e]));
|
|
136
|
+
let hasClickGoals = false;
|
|
137
|
+
|
|
138
|
+
for (const assignment of assignments) {
|
|
139
|
+
const exp = expMap.get(assignment.experimentId);
|
|
140
|
+
if (!exp?.goals) continue;
|
|
141
|
+
for (const goal of exp.goals) {
|
|
142
|
+
if (goal.type === "click" && (goal.selector || goal.textMatch)) {
|
|
143
|
+
activeClickGoals.push({ goal, assignment });
|
|
144
|
+
hasClickGoals = true;
|
|
145
|
+
}
|
|
146
|
+
if (goal.type === "pageview") {
|
|
147
|
+
if (!goal.urlPattern || safeRegexTest(goal.urlPattern, location.href)) {
|
|
148
|
+
fireGoal(goal, assignment);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (hasClickGoals) {
|
|
155
|
+
clickListener = handleClick;
|
|
156
|
+
document.addEventListener("click", clickListener, true);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function triggerGoal(
|
|
161
|
+
goalId: string,
|
|
162
|
+
assignments: Assignment[],
|
|
163
|
+
experiments: { id: string; goals?: Goal[] }[],
|
|
164
|
+
data?: Record<string, unknown>,
|
|
165
|
+
): void {
|
|
166
|
+
const expMap = new Map(experiments.map((e) => [e.id, e]));
|
|
167
|
+
for (const assignment of assignments) {
|
|
168
|
+
const exp = expMap.get(assignment.experimentId);
|
|
169
|
+
if (!exp?.goals) continue;
|
|
170
|
+
const goal = exp.goals.find((g) => g.id === goalId);
|
|
171
|
+
if (goal) fireGoal(goal, assignment, data);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function triggerGoalsForEvent(
|
|
176
|
+
eventName: string,
|
|
177
|
+
assignments: Assignment[],
|
|
178
|
+
experiments: { id: string; goals?: Goal[] }[],
|
|
179
|
+
data?: Record<string, unknown>,
|
|
180
|
+
): void {
|
|
181
|
+
if (!eventName) return;
|
|
182
|
+
const expMap = new Map(experiments.map((e) => [e.id, e]));
|
|
183
|
+
for (const assignment of assignments) {
|
|
184
|
+
const exp = expMap.get(assignment.experimentId);
|
|
185
|
+
if (!exp?.goals) continue;
|
|
186
|
+
for (const goal of exp.goals) {
|
|
187
|
+
if (goal.type === "custom" && goal.eventName === eventName) {
|
|
188
|
+
fireGoal(goal, assignment, data);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function cleanupGoals(): void {
|
|
195
|
+
activeClickGoals = [];
|
|
196
|
+
if (clickListener) {
|
|
197
|
+
document.removeEventListener("click", clickListener, true);
|
|
198
|
+
clickListener = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { __test, getQaSessionId, isQaMode, isScreenshotMode } from "./guards";
|
|
3
|
+
|
|
4
|
+
const { parseForceString, parseForceParams, sanitizeQaSessionId, BOT_RE } = __test;
|
|
5
|
+
|
|
6
|
+
describe("parseForceString", () => {
|
|
7
|
+
it("parses a single pair", () => {
|
|
8
|
+
expect(parseForceString("exp1:var1")).toEqual({ exp1: "var1" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("parses multiple comma-separated pairs", () => {
|
|
12
|
+
expect(parseForceString("exp1:var1,exp2:var2")).toEqual({
|
|
13
|
+
exp1: "var1",
|
|
14
|
+
exp2: "var2",
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null for empty string", () => {
|
|
19
|
+
expect(parseForceString("")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null for malformed input without colon", () => {
|
|
23
|
+
expect(parseForceString("nocolon")).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("parses valid pairs even with trailing comma", () => {
|
|
27
|
+
const result = parseForceString("exp1:var1,");
|
|
28
|
+
expect(result).toEqual({ exp1: "var1" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns null for only commas", () => {
|
|
32
|
+
expect(parseForceString(",,")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns null when colon only (both expId and varId are empty)", () => {
|
|
36
|
+
// ":" → split by "," gives [":"], split ":" gives ["", ""]
|
|
37
|
+
// expId = "" is falsy, so it's skipped → empty map → null
|
|
38
|
+
expect(parseForceString(":")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseForceParams", () => {
|
|
43
|
+
it("keeps supporting canonical drip_force pairs", () => {
|
|
44
|
+
expect(
|
|
45
|
+
parseForceParams(
|
|
46
|
+
new URLSearchParams("drip_force=exp_1%3Avar_1%2Cexp_2%3Avar_2"),
|
|
47
|
+
),
|
|
48
|
+
).toEqual({
|
|
49
|
+
exp_1: "var_1",
|
|
50
|
+
exp_2: "var_2",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("accepts direct experiment and variation id params", () => {
|
|
55
|
+
expect(
|
|
56
|
+
parseForceParams(
|
|
57
|
+
new URLSearchParams("drip_experiment_id=exp_1&drip_variation_id=var_1"),
|
|
58
|
+
),
|
|
59
|
+
).toEqual({ exp_1: "var_1" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("prefers canonical drip_force over aliased ids", () => {
|
|
63
|
+
expect(
|
|
64
|
+
parseForceParams(
|
|
65
|
+
new URLSearchParams(
|
|
66
|
+
"drip_force=exp_canonical%3Avar_canonical&drip_experiment_id=exp_alias&drip_variation_id=var_alias",
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
).toEqual({ exp_canonical: "var_canonical" });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("BOT_RE", () => {
|
|
74
|
+
it("detects googlebot", () => {
|
|
75
|
+
expect(BOT_RE.test("Mozilla/5.0 (compatible; Googlebot/2.1)")).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("detects bingpreview", () => {
|
|
79
|
+
expect(BOT_RE.test("Mozilla/5.0 BingPreview/1.0b")).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("detects facebookexternalhit", () => {
|
|
83
|
+
expect(BOT_RE.test("facebookexternalhit/1.1")).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("detects slurp", () => {
|
|
87
|
+
expect(BOT_RE.test("Mozilla/5.0 (compatible; Yahoo! Slurp)")).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("detects crawler", () => {
|
|
91
|
+
expect(BOT_RE.test("my-crawler/1.0")).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("rejects normal Chrome UA", () => {
|
|
95
|
+
expect(
|
|
96
|
+
BOT_RE.test(
|
|
97
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
|
|
98
|
+
),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects empty string", () => {
|
|
103
|
+
expect(BOT_RE.test("")).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("is case insensitive", () => {
|
|
107
|
+
expect(BOT_RE.test("GOOGLEBOT")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("detects spider", () => {
|
|
111
|
+
expect(BOT_RE.test("Mozilla/5.0 (compatible; spider/1.0)")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("detects robot", () => {
|
|
115
|
+
expect(BOT_RE.test("my-robot/2.0")).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("detects crawling", () => {
|
|
119
|
+
expect(BOT_RE.test("web-crawling-agent")).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("isScreenshotMode", () => {
|
|
124
|
+
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
|
125
|
+
|
|
126
|
+
function setSearch(search: string) {
|
|
127
|
+
Object.defineProperty(globalThis, "location", {
|
|
128
|
+
configurable: true,
|
|
129
|
+
value: { search },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
if (originalLocation) {
|
|
135
|
+
Object.defineProperty(globalThis, "location", originalLocation);
|
|
136
|
+
} else {
|
|
137
|
+
delete (globalThis as any).location;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("accepts drip_screenshot=1", () => {
|
|
142
|
+
setSearch("?drip_screenshot=1");
|
|
143
|
+
expect(isScreenshotMode()).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("accepts drip_screenshot=true", () => {
|
|
147
|
+
setSearch("?drip_screenshot=true");
|
|
148
|
+
expect(isScreenshotMode()).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("is off by default", () => {
|
|
152
|
+
setSearch("?drip_qa=1");
|
|
153
|
+
expect(isScreenshotMode()).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("isQaMode", () => {
|
|
158
|
+
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
|
159
|
+
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
|
|
160
|
+
|
|
161
|
+
function setSearch(search: string) {
|
|
162
|
+
Object.defineProperty(globalThis, "location", {
|
|
163
|
+
configurable: true,
|
|
164
|
+
value: { search },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
if (originalDocument) {
|
|
170
|
+
Object.defineProperty(globalThis, "document", originalDocument);
|
|
171
|
+
} else {
|
|
172
|
+
delete (globalThis as any).document;
|
|
173
|
+
}
|
|
174
|
+
if (originalLocation) {
|
|
175
|
+
Object.defineProperty(globalThis, "location", originalLocation);
|
|
176
|
+
} else {
|
|
177
|
+
delete (globalThis as any).location;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("lets screenshot mode override a persisted QA force cookie", () => {
|
|
182
|
+
Object.defineProperty(globalThis, "document", {
|
|
183
|
+
configurable: true,
|
|
184
|
+
value: { cookie: "drip_qa_force=exp_1%3Avar_1" },
|
|
185
|
+
});
|
|
186
|
+
setSearch("?drip_screenshot=1");
|
|
187
|
+
|
|
188
|
+
expect(isScreenshotMode()).toBe(true);
|
|
189
|
+
expect(isQaMode()).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("QA session IDs", () => {
|
|
194
|
+
const originalLocation = Object.getOwnPropertyDescriptor(globalThis, "location");
|
|
195
|
+
const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
|
|
196
|
+
|
|
197
|
+
function setSearch(search: string) {
|
|
198
|
+
Object.defineProperty(globalThis, "location", {
|
|
199
|
+
configurable: true,
|
|
200
|
+
value: { search },
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function installSessionStorage() {
|
|
205
|
+
const values = new Map<string, string>();
|
|
206
|
+
Object.defineProperty(globalThis, "sessionStorage", {
|
|
207
|
+
configurable: true,
|
|
208
|
+
value: {
|
|
209
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
210
|
+
setItem: (key: string, value: string) => {
|
|
211
|
+
values.set(key, value);
|
|
212
|
+
},
|
|
213
|
+
removeItem: (key: string) => {
|
|
214
|
+
values.delete(key);
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
afterEach(() => {
|
|
221
|
+
if (originalLocation) {
|
|
222
|
+
Object.defineProperty(globalThis, "location", originalLocation);
|
|
223
|
+
} else {
|
|
224
|
+
delete (globalThis as any).location;
|
|
225
|
+
}
|
|
226
|
+
if (originalSessionStorage) {
|
|
227
|
+
Object.defineProperty(globalThis, "sessionStorage", originalSessionStorage);
|
|
228
|
+
} else {
|
|
229
|
+
delete (globalThis as any).sessionStorage;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("accepts safe run IDs from the URL", () => {
|
|
234
|
+
setSearch("?drip_qa_session=run_123-abc");
|
|
235
|
+
installSessionStorage();
|
|
236
|
+
expect(getQaSessionId(() => "generated")).toBe("run_123-abc");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("falls back for unsafe run IDs", () => {
|
|
240
|
+
setSearch("?drip_qa_session=<script>");
|
|
241
|
+
installSessionStorage();
|
|
242
|
+
expect(getQaSessionId(() => "generated")).toBe("generated");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("sanitizes QA session IDs", () => {
|
|
246
|
+
expect(sanitizeQaSessionId("abc_123-xyz")).toBe("abc_123-xyz");
|
|
247
|
+
expect(sanitizeQaSessionId("no spaces")).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
});
|