@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
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { normalizeDataLayerEntry } from "./data-layer-normalize";
|
|
2
|
+
|
|
3
|
+
type Cleanup = () => void;
|
|
4
|
+
|
|
5
|
+
type DataLayerEntry = Record<string, unknown>;
|
|
6
|
+
type DataLayerMatcher = Record<string, unknown> | Array<[string, unknown]>;
|
|
7
|
+
|
|
8
|
+
type RuntimeToolsEnvironment = {
|
|
9
|
+
document?: Document;
|
|
10
|
+
window?: Window & typeof globalThis;
|
|
11
|
+
location?: Location;
|
|
12
|
+
track?: (type: string, data?: Record<string, unknown>) => void;
|
|
13
|
+
trackGoal?: (goalId: string, data?: Record<string, unknown>) => void;
|
|
14
|
+
signal?: (key: string, value: unknown, ttlDays?: number) => void;
|
|
15
|
+
reInit?: (forceReapply?: boolean) => unknown;
|
|
16
|
+
get?: (key: string) => unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WaitForElementOptions = {
|
|
20
|
+
root?: ParentNode;
|
|
21
|
+
once?: boolean;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type DomChangedOptions = {
|
|
26
|
+
root?: Node;
|
|
27
|
+
subtree?: boolean;
|
|
28
|
+
debounceMs?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ElementInViewOptions = {
|
|
32
|
+
root?: Element | Document | null;
|
|
33
|
+
rootMargin?: string;
|
|
34
|
+
threshold?: number;
|
|
35
|
+
once?: boolean;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type DataLayerOptions = {
|
|
40
|
+
layerName?: string;
|
|
41
|
+
once?: boolean;
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ApexRuntimeTools = {
|
|
46
|
+
waitForElement: (
|
|
47
|
+
selector: string,
|
|
48
|
+
callback: (element: Element) => void,
|
|
49
|
+
options?: WaitForElementOptions,
|
|
50
|
+
) => Cleanup;
|
|
51
|
+
elementIsInView: (
|
|
52
|
+
selector: string,
|
|
53
|
+
callback: (element: Element) => void,
|
|
54
|
+
options?: ElementInViewOptions | boolean,
|
|
55
|
+
) => Cleanup;
|
|
56
|
+
domChanged: (
|
|
57
|
+
callback: (mutations: MutationRecord[]) => void,
|
|
58
|
+
options?: DomChangedOptions,
|
|
59
|
+
) => Cleanup;
|
|
60
|
+
domLoaded: (callback: () => void) => Cleanup;
|
|
61
|
+
waitForDataLayer: (
|
|
62
|
+
matcher: DataLayerMatcher,
|
|
63
|
+
callback: (entry: DataLayerEntry) => void,
|
|
64
|
+
options?: DataLayerOptions,
|
|
65
|
+
) => Cleanup;
|
|
66
|
+
track: (type: string, data?: Record<string, unknown>) => void;
|
|
67
|
+
trackGoal: (goalId: string, data?: Record<string, unknown>) => void;
|
|
68
|
+
signal: (key: string, value: unknown, ttlDays?: number) => void;
|
|
69
|
+
redirect: (target: string) => void;
|
|
70
|
+
reInit: (forceReapply?: boolean) => unknown;
|
|
71
|
+
get: (key: string) => unknown;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function noopCleanup(): Cleanup {
|
|
75
|
+
return () => {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readDocument(env: RuntimeToolsEnvironment): Document | undefined {
|
|
79
|
+
return env.document ?? (typeof document !== "undefined" ? document : undefined);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readWindow(
|
|
83
|
+
env: RuntimeToolsEnvironment,
|
|
84
|
+
): (Window & typeof globalThis) | undefined {
|
|
85
|
+
return env.window ?? (typeof window !== "undefined" ? window : undefined);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readLocation(env: RuntimeToolsEnvironment): Location | undefined {
|
|
89
|
+
return env.location ?? (typeof location !== "undefined" ? location : undefined);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function safeCallback<T>(callback: (value: T) => void, value: T): void {
|
|
93
|
+
try {
|
|
94
|
+
callback(value);
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isElement(value: unknown): value is Element {
|
|
99
|
+
return typeof Element !== "undefined" && value instanceof Element;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isDocument(value: unknown): value is Document {
|
|
103
|
+
return typeof Document !== "undefined" && value instanceof Document;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function collectMatches(root: ParentNode, selector: string): Element[] {
|
|
107
|
+
const matches: Element[] = [];
|
|
108
|
+
if (isElement(root) && root.matches(selector)) {
|
|
109
|
+
matches.push(root);
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
root.querySelectorAll(selector).forEach((element) => matches.push(element));
|
|
113
|
+
} catch {}
|
|
114
|
+
return matches;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function observeRootFor(root: ParentNode, doc: Document): Node | null {
|
|
118
|
+
if (isDocument(root)) {
|
|
119
|
+
return root.documentElement ?? root.body;
|
|
120
|
+
}
|
|
121
|
+
if (isElement(root)) return root;
|
|
122
|
+
return doc.documentElement ?? doc.body;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeInViewOptions(
|
|
126
|
+
options: ElementInViewOptions | boolean | undefined,
|
|
127
|
+
): ElementInViewOptions {
|
|
128
|
+
if (typeof options === "boolean") return { once: !options };
|
|
129
|
+
return options ?? {};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeMatcher(matcher: DataLayerMatcher): Array<[string, unknown]> {
|
|
133
|
+
return Array.isArray(matcher) ? matcher : Object.entries(matcher);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function matchesDataLayerEntry(
|
|
137
|
+
entry: unknown,
|
|
138
|
+
matcher: DataLayerMatcher,
|
|
139
|
+
): entry is DataLayerEntry {
|
|
140
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
141
|
+
const record = entry as DataLayerEntry;
|
|
142
|
+
return normalizeMatcher(matcher).every(([key, expected]) => {
|
|
143
|
+
if (expected instanceof RegExp) {
|
|
144
|
+
return expected.test(String(record[key] ?? ""));
|
|
145
|
+
}
|
|
146
|
+
return record[key] === expected;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createApexRuntimeTools(
|
|
151
|
+
env: RuntimeToolsEnvironment = {},
|
|
152
|
+
): ApexRuntimeTools {
|
|
153
|
+
const tools: ApexRuntimeTools = {
|
|
154
|
+
waitForElement(
|
|
155
|
+
selector: string,
|
|
156
|
+
callback: (element: Element) => void,
|
|
157
|
+
options: WaitForElementOptions = {},
|
|
158
|
+
): Cleanup {
|
|
159
|
+
const doc = readDocument(env);
|
|
160
|
+
if (!doc || !selector.trim()) return noopCleanup();
|
|
161
|
+
const root = options.root ?? doc;
|
|
162
|
+
const seen = new WeakSet<Element>();
|
|
163
|
+
let active = true;
|
|
164
|
+
let observer: MutationObserver | null = null;
|
|
165
|
+
let timeoutId: number | null = null;
|
|
166
|
+
|
|
167
|
+
const stop = () => {
|
|
168
|
+
active = false;
|
|
169
|
+
observer?.disconnect();
|
|
170
|
+
observer = null;
|
|
171
|
+
if (timeoutId !== null) {
|
|
172
|
+
readWindow(env)?.clearTimeout(timeoutId);
|
|
173
|
+
timeoutId = null;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handle = (scope: ParentNode) => {
|
|
178
|
+
if (!active) return;
|
|
179
|
+
for (const element of collectMatches(scope, selector)) {
|
|
180
|
+
if (seen.has(element)) continue;
|
|
181
|
+
seen.add(element);
|
|
182
|
+
safeCallback(callback, element);
|
|
183
|
+
if (options.once !== false) {
|
|
184
|
+
stop();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
handle(root);
|
|
191
|
+
if (!active || typeof MutationObserver === "undefined") return stop;
|
|
192
|
+
|
|
193
|
+
const observeTarget = observeRootFor(root, doc);
|
|
194
|
+
if (!observeTarget) return stop;
|
|
195
|
+
observer = new MutationObserver((mutations) => {
|
|
196
|
+
for (const mutation of mutations) {
|
|
197
|
+
mutation.addedNodes.forEach((node) => {
|
|
198
|
+
if (isElement(node)) handle(node);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
observer.observe(observeTarget, { childList: true, subtree: true });
|
|
203
|
+
|
|
204
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
205
|
+
timeoutId = readWindow(env)?.setTimeout(stop, options.timeoutMs) ?? null;
|
|
206
|
+
}
|
|
207
|
+
return stop;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
elementIsInView(
|
|
211
|
+
selector: string,
|
|
212
|
+
callback: (element: Element) => void,
|
|
213
|
+
rawOptions?: ElementInViewOptions | boolean,
|
|
214
|
+
): Cleanup {
|
|
215
|
+
const doc = readDocument(env);
|
|
216
|
+
const win = readWindow(env);
|
|
217
|
+
if (!doc || !win || !selector.trim()) return noopCleanup();
|
|
218
|
+
const options = normalizeInViewOptions(rawOptions);
|
|
219
|
+
const once = options.once !== false;
|
|
220
|
+
const observed = new WeakSet<Element>();
|
|
221
|
+
const cleanups: Cleanup[] = [];
|
|
222
|
+
let observer: IntersectionObserver | null = null;
|
|
223
|
+
|
|
224
|
+
const stop = () => {
|
|
225
|
+
observer?.disconnect();
|
|
226
|
+
observer = null;
|
|
227
|
+
cleanups.splice(0).forEach((cleanup) => cleanup());
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
231
|
+
return tools.waitForElement(
|
|
232
|
+
selector,
|
|
233
|
+
(element: Element) => {
|
|
234
|
+
const rect = element.getBoundingClientRect();
|
|
235
|
+
const inView =
|
|
236
|
+
rect.width > 0 &&
|
|
237
|
+
rect.height > 0 &&
|
|
238
|
+
rect.bottom >= 0 &&
|
|
239
|
+
rect.right >= 0 &&
|
|
240
|
+
rect.top <= win.innerHeight &&
|
|
241
|
+
rect.left <= win.innerWidth;
|
|
242
|
+
if (inView) safeCallback(callback, element);
|
|
243
|
+
},
|
|
244
|
+
{ once, timeoutMs: options.timeoutMs },
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
observer = new IntersectionObserver(
|
|
249
|
+
(entries) => {
|
|
250
|
+
for (const entry of entries) {
|
|
251
|
+
if (!entry.isIntersecting) continue;
|
|
252
|
+
safeCallback(callback, entry.target);
|
|
253
|
+
if (once) observer?.unobserve(entry.target);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
root: isDocument(options.root) ? null : (options.root ?? null),
|
|
258
|
+
rootMargin: options.rootMargin,
|
|
259
|
+
threshold: options.threshold ?? 1,
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
cleanups.push(
|
|
264
|
+
tools.waitForElement(
|
|
265
|
+
selector,
|
|
266
|
+
(element: Element) => {
|
|
267
|
+
if (observed.has(element)) return;
|
|
268
|
+
observed.add(element);
|
|
269
|
+
observer?.observe(element);
|
|
270
|
+
},
|
|
271
|
+
{ once: false, timeoutMs: options.timeoutMs },
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return stop;
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
domChanged(
|
|
278
|
+
callback: (mutations: MutationRecord[]) => void,
|
|
279
|
+
options: DomChangedOptions = {},
|
|
280
|
+
): Cleanup {
|
|
281
|
+
const doc = readDocument(env);
|
|
282
|
+
const win = readWindow(env);
|
|
283
|
+
if (!doc || typeof MutationObserver === "undefined") return noopCleanup();
|
|
284
|
+
const root = options.root ?? doc.documentElement ?? doc.body;
|
|
285
|
+
if (!root) return noopCleanup();
|
|
286
|
+
const debounceMs = Math.max(0, options.debounceMs ?? 50);
|
|
287
|
+
let pending: MutationRecord[] = [];
|
|
288
|
+
let timeoutId: number | null = null;
|
|
289
|
+
const flush = () => {
|
|
290
|
+
timeoutId = null;
|
|
291
|
+
const mutations = pending;
|
|
292
|
+
pending = [];
|
|
293
|
+
safeCallback(callback, mutations);
|
|
294
|
+
};
|
|
295
|
+
const observer = new MutationObserver((mutations) => {
|
|
296
|
+
pending.push(...mutations);
|
|
297
|
+
if (debounceMs === 0) {
|
|
298
|
+
flush();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (timeoutId !== null) return;
|
|
302
|
+
timeoutId = win?.setTimeout(flush, debounceMs) ?? null;
|
|
303
|
+
});
|
|
304
|
+
observer.observe(root, { childList: true, subtree: options.subtree !== false });
|
|
305
|
+
return () => {
|
|
306
|
+
observer.disconnect();
|
|
307
|
+
if (timeoutId !== null) win?.clearTimeout(timeoutId);
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
domLoaded(callback: () => void): Cleanup {
|
|
312
|
+
const doc = readDocument(env);
|
|
313
|
+
if (!doc || doc.readyState !== "loading") {
|
|
314
|
+
safeCallback(callback, undefined);
|
|
315
|
+
return noopCleanup();
|
|
316
|
+
}
|
|
317
|
+
const handler = () => safeCallback(callback, undefined);
|
|
318
|
+
doc.addEventListener("DOMContentLoaded", handler, { once: true });
|
|
319
|
+
return () => doc.removeEventListener("DOMContentLoaded", handler);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
waitForDataLayer(
|
|
323
|
+
matcher: DataLayerMatcher,
|
|
324
|
+
callback: (entry: DataLayerEntry) => void,
|
|
325
|
+
options: DataLayerOptions = {},
|
|
326
|
+
): Cleanup {
|
|
327
|
+
const win = readWindow(env);
|
|
328
|
+
if (!win) return noopCleanup();
|
|
329
|
+
const layerName = options.layerName || "dataLayer";
|
|
330
|
+
const layerHost = win as unknown as Record<string, unknown>;
|
|
331
|
+
const layer = Array.isArray(layerHost[layerName])
|
|
332
|
+
? (layerHost[layerName] as unknown[])
|
|
333
|
+
: [];
|
|
334
|
+
layerHost[layerName] = layer;
|
|
335
|
+
const originalPush = layer.push;
|
|
336
|
+
let active = true;
|
|
337
|
+
let timeoutId: number | null = null;
|
|
338
|
+
|
|
339
|
+
const process = (entry: unknown) => {
|
|
340
|
+
if (!active) return;
|
|
341
|
+
const normalized = normalizeDataLayerEntry(entry);
|
|
342
|
+
const rawRecord = entry && typeof entry === "object" && !Array.isArray(entry)
|
|
343
|
+
? entry as DataLayerEntry
|
|
344
|
+
: null;
|
|
345
|
+
const candidate = normalized ?? (
|
|
346
|
+
rawRecord && typeof rawRecord[0] !== "string" ? rawRecord : null
|
|
347
|
+
);
|
|
348
|
+
if (!candidate || !matchesDataLayerEntry(candidate, matcher)) return;
|
|
349
|
+
safeCallback(callback, candidate);
|
|
350
|
+
if (options.once !== false) cleanup();
|
|
351
|
+
};
|
|
352
|
+
const cleanup = () => {
|
|
353
|
+
active = false;
|
|
354
|
+
if (layer.push === wrappedPush) layer.push = originalPush;
|
|
355
|
+
if (timeoutId !== null) {
|
|
356
|
+
win.clearTimeout(timeoutId);
|
|
357
|
+
timeoutId = null;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const wrappedPush = function (...items: unknown[]) {
|
|
361
|
+
const result = originalPush.apply(layer, items);
|
|
362
|
+
items.forEach(process);
|
|
363
|
+
return result;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
layer.slice(Math.max(0, layer.length - 50)).forEach(process);
|
|
367
|
+
if (active) layer.push = wrappedPush as typeof layer.push;
|
|
368
|
+
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
369
|
+
timeoutId = win.setTimeout(cleanup, options.timeoutMs);
|
|
370
|
+
}
|
|
371
|
+
return cleanup;
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
track(type: string, data?: Record<string, unknown>) {
|
|
375
|
+
env.track?.(type, data);
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
trackGoal(goalId: string, data?: Record<string, unknown>) {
|
|
379
|
+
env.trackGoal?.(goalId, data);
|
|
380
|
+
},
|
|
381
|
+
|
|
382
|
+
signal(key: string, value: unknown, ttlDays?: number) {
|
|
383
|
+
env.signal?.(key, value, ttlDays);
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
redirect(target: string) {
|
|
387
|
+
const loc = readLocation(env);
|
|
388
|
+
if (!loc || !target.trim()) return;
|
|
389
|
+
loc.assign(new URL(target, loc.href).toString());
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
reInit(forceReapply?: boolean) {
|
|
393
|
+
return env.reInit?.(forceReapply);
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
get(key: string) {
|
|
397
|
+
if (key === "tools") return tools;
|
|
398
|
+
return env.get?.(key);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return tools;
|
|
403
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assignmentSignature,
|
|
4
|
+
canUseEmptyProjectProfile,
|
|
5
|
+
computeUrlDiffNeeds,
|
|
6
|
+
readPerfFlags,
|
|
7
|
+
resolveActivationMode,
|
|
8
|
+
resolvePlatform,
|
|
9
|
+
resolveRouterMode,
|
|
10
|
+
shouldReevaluateForUrlChange,
|
|
11
|
+
} from "./runtime";
|
|
12
|
+
import type { InitConfig } from "./types";
|
|
13
|
+
|
|
14
|
+
const originalShopify = (globalThis as Record<string, unknown>).Shopify;
|
|
15
|
+
const originalNavigation = (globalThis as Record<string, unknown>).navigation;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
const g = globalThis as Record<string, unknown>;
|
|
19
|
+
g.Shopify = originalShopify;
|
|
20
|
+
g.navigation = originalNavigation;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("platform/router runtime", () => {
|
|
24
|
+
it("prefers explicit platformHint over profile and detection", () => {
|
|
25
|
+
const g = globalThis as Record<string, unknown>;
|
|
26
|
+
g.Shopify = { analytics: { publish: () => {} } };
|
|
27
|
+
const config: InitConfig = {
|
|
28
|
+
platformHint: "spa",
|
|
29
|
+
profile: { platform: "shopify", version: 1 },
|
|
30
|
+
experiments: [],
|
|
31
|
+
};
|
|
32
|
+
expect(resolvePlatform(config)).toBe("spa");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("uses profile platform when platformHint is auto", () => {
|
|
36
|
+
const config: InitConfig = {
|
|
37
|
+
platformHint: "auto",
|
|
38
|
+
profile: { platform: "shopify", version: 1 },
|
|
39
|
+
experiments: [],
|
|
40
|
+
};
|
|
41
|
+
expect(resolvePlatform(config)).toBe("shopify");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults router mode to none for shopify", () => {
|
|
45
|
+
const config: InitConfig = { experiments: [] };
|
|
46
|
+
expect(resolveRouterMode(config, "shopify")).toBe("none");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("respects navigation mode auto-detection", () => {
|
|
50
|
+
const g = globalThis as Record<string, unknown>;
|
|
51
|
+
g.navigation = { addEventListener: () => {} };
|
|
52
|
+
const config: InitConfig = { experiments: [] };
|
|
53
|
+
expect(resolveRouterMode(config, "spa")).toBe("navigation");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("resolves activation mode to observer by default", () => {
|
|
57
|
+
expect(resolveActivationMode({ experiments: [] })).toBe("observer");
|
|
58
|
+
expect(resolveActivationMode({ experiments: [], activationMode: "manual" })).toBe("manual");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("builds URL diff needs from targeting rules", () => {
|
|
62
|
+
const deps = computeUrlDiffNeeds([
|
|
63
|
+
{
|
|
64
|
+
id: "exp_1",
|
|
65
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
66
|
+
targeting: {
|
|
67
|
+
url: [{ type: "simple", pattern: "example.com/products/*?q=1#hero" }],
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
expect(deps.path).toBe(true);
|
|
73
|
+
expect(deps.search).toBe(true);
|
|
74
|
+
expect(deps.hash).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("builds URL diff needs from route-captured runtime signals", () => {
|
|
78
|
+
const queryDeps = computeUrlDiffNeeds([], [
|
|
79
|
+
{
|
|
80
|
+
key: "utm.content",
|
|
81
|
+
capture: { mode: "url_parameter", parameter: "utm_content" },
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
expect(queryDeps).toEqual({ path: false, search: true, hash: false });
|
|
85
|
+
|
|
86
|
+
const domDeps = computeUrlDiffNeeds([], [
|
|
87
|
+
{
|
|
88
|
+
key: "product.type",
|
|
89
|
+
capture: { mode: "dom_selector", selector: "[data-product-type]" },
|
|
90
|
+
},
|
|
91
|
+
]);
|
|
92
|
+
expect(domDeps).toEqual({ path: true, search: true, hash: true });
|
|
93
|
+
|
|
94
|
+
const globalDeps = computeUrlDiffNeeds([], [
|
|
95
|
+
{
|
|
96
|
+
key: "shopware.cart_count",
|
|
97
|
+
capture: { mode: "global", path: "Shopware.State.cart.count" },
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
expect(globalDeps).toEqual({ path: true, search: true, hash: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("skips reevaluation when unrelated URL parts change", () => {
|
|
104
|
+
const deps = { path: true, search: false, hash: false };
|
|
105
|
+
expect(
|
|
106
|
+
shouldReevaluateForUrlChange(
|
|
107
|
+
"https://example.com/products/a?q=1",
|
|
108
|
+
"https://example.com/products/a?q=2",
|
|
109
|
+
deps,
|
|
110
|
+
),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("reevaluates when drip_force changes even without URL targeting deps", () => {
|
|
115
|
+
const deps = { path: false, search: false, hash: false };
|
|
116
|
+
expect(
|
|
117
|
+
shouldReevaluateForUrlChange(
|
|
118
|
+
"https://example.com/products/a?drip_force=exp_1:var_a",
|
|
119
|
+
"https://example.com/products/b?drip_force=exp_1:var_b",
|
|
120
|
+
deps,
|
|
121
|
+
),
|
|
122
|
+
).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reevaluates when readable force params change without URL targeting deps", () => {
|
|
126
|
+
const deps = { path: false, search: false, hash: false };
|
|
127
|
+
expect(
|
|
128
|
+
shouldReevaluateForUrlChange(
|
|
129
|
+
"https://example.com/products/a?drip_experiment_id=exp_1&drip_variation_id=var_a",
|
|
130
|
+
"https://example.com/products/b?drip_experiment_id=exp_1&drip_variation_id=var_b",
|
|
131
|
+
deps,
|
|
132
|
+
),
|
|
133
|
+
).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("produces deterministic assignment signatures", () => {
|
|
137
|
+
const a = assignmentSignature([
|
|
138
|
+
{
|
|
139
|
+
experimentId: "exp_b",
|
|
140
|
+
variationId: "var_2",
|
|
141
|
+
} as any,
|
|
142
|
+
{
|
|
143
|
+
experimentId: "exp_a",
|
|
144
|
+
variationId: "var_1",
|
|
145
|
+
} as any,
|
|
146
|
+
]);
|
|
147
|
+
const b = assignmentSignature([
|
|
148
|
+
{
|
|
149
|
+
experimentId: "exp_a",
|
|
150
|
+
variationId: "var_1",
|
|
151
|
+
} as any,
|
|
152
|
+
{
|
|
153
|
+
experimentId: "exp_b",
|
|
154
|
+
variationId: "var_2",
|
|
155
|
+
} as any,
|
|
156
|
+
]);
|
|
157
|
+
expect(a).toBe(b);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("allows perf flag overrides per init config", () => {
|
|
161
|
+
const flags = readPerfFlags({
|
|
162
|
+
experiments: [],
|
|
163
|
+
perfFlags: { perf_spa_incremental_reapply_v1: false },
|
|
164
|
+
});
|
|
165
|
+
expect(flags.perf_platform_router_v1).toBe(true);
|
|
166
|
+
expect(flags.perf_spa_incremental_reapply_v1).toBe(false);
|
|
167
|
+
expect(flags.perf_empty_project_v1).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("accepts only server-authorized, behavior-free empty projects", () => {
|
|
171
|
+
const config: InitConfig = {
|
|
172
|
+
experiments: [],
|
|
173
|
+
perfFlags: { perf_empty_project_v1: true },
|
|
174
|
+
runtimeFeatures: { sdkVariant: "live" },
|
|
175
|
+
runtimeProfile: "empty_project",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
expect(canUseEmptyProjectProfile(config, [], "shopify")).toBe(true);
|
|
179
|
+
expect(canUseEmptyProjectProfile({ ...config, runtimeProfile: "full" }, [], "shopify")).toBe(false);
|
|
180
|
+
expect(canUseEmptyProjectProfile({ ...config, perfFlags: {} }, [], "shopify")).toBe(false);
|
|
181
|
+
expect(canUseEmptyProjectProfile({ ...config, signals: [{ key: "cart.count" }] }, [], "shopify")).toBe(false);
|
|
182
|
+
expect(canUseEmptyProjectProfile({ ...config, projectCode: { enabled: true, js: "run()" } }, [], "shopify")).toBe(false);
|
|
183
|
+
expect(canUseEmptyProjectProfile({ ...config, externalTracking: { domains: ["checkout.test"] } }, [], "shopify")).toBe(false);
|
|
184
|
+
expect(canUseEmptyProjectProfile(config, [{ id: "exp_1", variations: [] }], "shopify")).toBe(false);
|
|
185
|
+
expect(canUseEmptyProjectProfile(config, [], "spa")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it.each<Array<[string, Partial<InitConfig>]>>([
|
|
189
|
+
["data-layer triggers", { dataLayerTriggers: [{ event: "cart_updated" }] }],
|
|
190
|
+
["exposure destinations", { exposureDestinations: { dataLayer: { enabled: true } } }],
|
|
191
|
+
["semantic tracking", { semanticTracking: { enabled: true } }],
|
|
192
|
+
["debug mode", { debug: { enabled: true } }],
|
|
193
|
+
["runtime feature flags", { runtimeFeatures: { sdkVariant: "live", heatmap: true } }],
|
|
194
|
+
["page triggers", { pageTrigger: { defaultMode: "dom_change" } }],
|
|
195
|
+
["top-level router mode", { routerMode: "history" }],
|
|
196
|
+
["SPA reinitialization", { spa: { reinitOnly: true } }],
|
|
197
|
+
["SPA DOM observation", { spa: { domChangeTrigger: true } }],
|
|
198
|
+
["SPA manual activation", { spa: { activationMode: "manual" } }],
|
|
199
|
+
["SPA router mode", { spa: { routerMode: "history" } }],
|
|
200
|
+
["variation prerequisites", {
|
|
201
|
+
projectPrerequisites: { showVariationsRule: "return false;" },
|
|
202
|
+
}],
|
|
203
|
+
["tracking prerequisites", {
|
|
204
|
+
projectPrerequisites: { startTrackingRule: "return false;" },
|
|
205
|
+
}],
|
|
206
|
+
])("rejects empty-project profiles with %s", (_label, override) => {
|
|
207
|
+
const config: InitConfig = {
|
|
208
|
+
experiments: [],
|
|
209
|
+
perfFlags: { perf_empty_project_v1: true },
|
|
210
|
+
runtimeFeatures: { sdkVariant: "live" },
|
|
211
|
+
runtimeProfile: "empty_project",
|
|
212
|
+
...override,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
expect(canUseEmptyProjectProfile(config, [], "shopify")).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|