@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,191 @@
1
+ import type { Mutation } from "./types";
2
+ import { matchesOriginalInnerHtmlSnapshot, normalizeCssPropertyName } from "./mutate";
3
+ import { registerMutedObserver, runMuted, unregisterMutedObserver } from "./observer-mute";
4
+
5
+ type Entry = {
6
+ el: Element;
7
+ mutation: Mutation;
8
+ experimentId?: string;
9
+ variationId?: string;
10
+ appliedAt: number;
11
+ apply: (element: Element, mutation: Mutation) => void;
12
+ times: number[];
13
+ stopped: boolean;
14
+ appliedNodes?: ChildNode[];
15
+ appliedStyles?: Array<[string, string]>;
16
+ };
17
+
18
+ type TreatedRoot = {
19
+ entries: Entry[];
20
+ observer: MutationObserver;
21
+ };
22
+
23
+ // For html treatments, remember the nodes the treatment put in place and
24
+ // attribute subtree changes: a framework hydration revert either replaces
25
+ // them wholesale or restores the pre-treatment original content in place
26
+ // (React/Vue reconciliation), while variant-owned widget activity (a
27
+ // carousel adding slides, a countdown updating text, variation JS appending
28
+ // state) evolves the applied content into something the original never had.
29
+ // Wholesale replaces reassert at any time; in-place full restores reassert
30
+ // within the hydration window; everything else is treated as variant-owned.
31
+ // Partial in-place patches that do not restore the full original are not
32
+ // separable from widget writes — sites that hit that corner integrate via
33
+ // the drip:hydrated event / hold() API.
34
+ const IN_PLACE_REVERT_WINDOW_MS = 10_000;
35
+
36
+ function captureAppliedNodes(entry: Entry): void {
37
+ if (entry.mutation.action === "html") {
38
+ entry.appliedNodes = Array.from(entry.el.childNodes);
39
+ } else if (entry.mutation.action === "style" || entry.mutation.action === "setStyle") {
40
+ const style = (entry.el as HTMLElement).style;
41
+ entry.appliedStyles = Object.keys(entry.mutation.styles ?? {}).map((name) => [
42
+ name = normalizeCssPropertyName(name),
43
+ style.getPropertyValue(name),
44
+ ]);
45
+ }
46
+ }
47
+
48
+ function variantContentSurvives(entry: Entry, needsDeepCheck: boolean): boolean {
49
+ const surviving = (entry.appliedNodes ?? []).some(
50
+ (node) => node.isConnected && entry.el.contains(node),
51
+ );
52
+ if (!surviving) return false;
53
+ if (Date.now() - entry.appliedAt > IN_PLACE_REVERT_WINDOW_MS) return true;
54
+ if (!needsDeepCheck) return true;
55
+ // Any captured pre-state counts as a revert target: the server original,
56
+ // or — with stacked html treatments — an earlier treatment's content.
57
+ return !matchesOriginalInnerHtmlSnapshot(entry.el, entry.el.innerHTML);
58
+ }
59
+
60
+ const pending = new Set<Entry>();
61
+ const deepChecks = new Set<Entry>();
62
+ const roots = new Map<Element, TreatedRoot>();
63
+ let frame: number | null = null;
64
+
65
+ function perf(): Record<string, unknown> | null {
66
+ if (typeof window === "undefined") return null;
67
+ const target = window as Window & { __dripPerf?: Record<string, unknown> };
68
+ return target.__dripPerf = target.__dripPerf ?? {};
69
+ }
70
+
71
+ function matches(entry: Entry, record: MutationRecord): boolean {
72
+ if (!entry.el.isConnected) return false;
73
+ const action = entry.mutation.action;
74
+ if (action === "style" || action === "setStyle") {
75
+ if (record.type !== "attributes" || record.target !== entry.el || record.attributeName !== "style") return false;
76
+ const style = (entry.el as HTMLElement).style;
77
+ return entry.appliedStyles!.some(
78
+ ([name, value]) => style.getPropertyValue(name) !== value,
79
+ );
80
+ }
81
+ if (action === "attribute") {
82
+ if (record.type !== "attributes" || record.target !== entry.el || !record.attributeName) return false;
83
+ const expected = record.attributeName === entry.mutation.attribute?.name
84
+ ? entry.mutation.attribute.value
85
+ : entry.mutation.attributes?.[record.attributeName];
86
+ return expected !== undefined && entry.el.getAttribute(record.attributeName) !== expected;
87
+ }
88
+ if (action === "html") return true;
89
+ return record.type !== "attributes" && entry.el.textContent !== entry.mutation.text;
90
+ }
91
+
92
+ function markPending(entry: Entry): void {
93
+ pending.add(entry);
94
+ if (entry.mutation.action === "html") deepChecks.add(entry);
95
+ }
96
+
97
+ function schedule(): void {
98
+ if (frame !== null) return;
99
+ frame = requestAnimationFrame(() => {
100
+ frame = null;
101
+ const now = Date.now();
102
+ for (const entry of pending) {
103
+ if (entry.stopped) continue;
104
+ entry.times = entry.times.filter((at) => now - at < 10_000);
105
+ if (entry.times.length === 12) {
106
+ entry.stopped = true;
107
+ const metrics = perf();
108
+ if (metrics) metrics.reassert_loop_detected = true;
109
+ continue;
110
+ }
111
+ if (entry.mutation.action === "html" && variantContentSurvives(entry, deepChecks.has(entry))) {
112
+ // The change came from inside the still-applied treatment; erasing
113
+ // it would destroy legitimate widget state. Track the evolved
114
+ // subtree instead of reasserting the original static HTML.
115
+ captureAppliedNodes(entry);
116
+ continue;
117
+ }
118
+ entry.times.push(now);
119
+ runMuted(() => entry.apply(entry.el, entry.mutation));
120
+ captureAppliedNodes(entry);
121
+ const metrics = perf();
122
+ if (metrics) metrics.reapply_count = (Number(metrics.reapply_count) || 0) + 1;
123
+ if (entry.mutation.action === "html") {
124
+ document.dispatchEvent(new CustomEvent("drip:mutation-reapplied", { detail: {
125
+ experimentId: entry.experimentId,
126
+ variationId: entry.variationId,
127
+ selector: entry.mutation.selector,
128
+ action: entry.mutation.action,
129
+ } }));
130
+ }
131
+ }
132
+ pending.clear();
133
+ deepChecks.clear();
134
+ });
135
+ }
136
+
137
+ function observe(element: Element): void {
138
+ if (typeof MutationObserver === "undefined" || roots.has(element)) return;
139
+ for (const [treatedRoot, { observer }] of roots) {
140
+ if (treatedRoot.isConnected) continue;
141
+ observer.disconnect();
142
+ unregisterMutedObserver(observer);
143
+ roots.delete(treatedRoot);
144
+ }
145
+ const entries: Entry[] = [];
146
+ const onRecords: MutationCallback = (records) => {
147
+ for (const record of records) {
148
+ for (const entry of entries) {
149
+ if (!entry.stopped && matches(entry, record)) markPending(entry);
150
+ }
151
+ }
152
+ if (pending.size) schedule();
153
+ };
154
+ const observer = new MutationObserver(onRecords);
155
+ roots.set(element, { entries, observer });
156
+ registerMutedObserver(observer, onRecords);
157
+ observer.observe(element, { attributes: true, characterData: true, childList: true, subtree: true });
158
+ }
159
+
160
+ export function trackAppliedMutation(
161
+ element: Element,
162
+ mutation: Mutation,
163
+ context: { experimentId?: string; variationId?: string },
164
+ apply: (element: Element, mutation: Mutation) => void,
165
+ ): void {
166
+ if (mutation.action === "remove" || mutation.action === "insertBefore" || mutation.action === "insertAfter") return;
167
+ observe(element);
168
+ const entries = roots.get(element)?.entries;
169
+ if (!entries) return;
170
+ const existing = entries.find((entry) => entry.mutation === mutation);
171
+ if (existing) {
172
+ Object.assign(existing, context, { appliedAt: Date.now(), apply });
173
+ captureAppliedNodes(existing);
174
+ } else {
175
+ const entry: Entry = { el: element, mutation, ...context, appliedAt: Date.now(), apply, times: [], stopped: false };
176
+ captureAppliedNodes(entry);
177
+ entries.push(entry);
178
+ }
179
+ }
180
+
181
+ export function resetReassertSentinel(): void {
182
+ if (frame !== null) cancelAnimationFrame(frame);
183
+ frame = null;
184
+ pending.clear();
185
+ deepChecks.clear();
186
+ for (const { observer } of roots.values()) {
187
+ observer.disconnect();
188
+ unregisterMutedObserver(observer);
189
+ }
190
+ roots.clear();
191
+ }
@@ -0,0 +1,78 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { observeRouteChanges } from "./router";
3
+
4
+ describe("route observer", () => {
5
+ const originalWindow = (globalThis as any).window;
6
+ const originalLocation = (globalThis as any).location;
7
+ const originalHistory = (globalThis as any).history;
8
+ let listeners: Map<string, Set<() => void>>;
9
+
10
+ function setLocation(nextHref: string): void {
11
+ const parsed = new URL(nextHref);
12
+ (globalThis as any).location = {
13
+ hash: parsed.hash,
14
+ href: parsed.href,
15
+ origin: parsed.origin,
16
+ pathname: parsed.pathname,
17
+ search: parsed.search,
18
+ };
19
+ }
20
+
21
+ function dispatch(type: string): void {
22
+ for (const listener of listeners.get(type) ?? []) {
23
+ listener();
24
+ }
25
+ }
26
+
27
+ beforeEach(() => {
28
+ listeners = new Map();
29
+ setLocation("https://example.test/products/a#control");
30
+ (globalThis as any).history = {
31
+ pushState() {},
32
+ replaceState() {},
33
+ };
34
+ (globalThis as any).window = {
35
+ addEventListener(type: string, listener: () => void) {
36
+ const set = listeners.get(type) ?? new Set<() => void>();
37
+ set.add(listener);
38
+ listeners.set(type, set);
39
+ },
40
+ removeEventListener(type: string, listener: () => void) {
41
+ listeners.get(type)?.delete(listener);
42
+ },
43
+ };
44
+ });
45
+
46
+ afterEach(() => {
47
+ (globalThis as any).window = originalWindow;
48
+ (globalThis as any).location = originalLocation;
49
+ (globalThis as any).history = originalHistory;
50
+ });
51
+
52
+ it("observes pure hashchange events when history does not emit popstate", () => {
53
+ const events: Array<{
54
+ previousUrl: string;
55
+ nextUrl: string;
56
+ reason: string;
57
+ }> = [];
58
+ const stop = observeRouteChanges("history", (event) => {
59
+ events.push(event);
60
+ });
61
+
62
+ setLocation("https://example.test/products/a#variant");
63
+ dispatch("hashchange");
64
+
65
+ expect(events).toEqual([
66
+ {
67
+ previousUrl: "https://example.test/products/a#control",
68
+ nextUrl: "https://example.test/products/a#variant",
69
+ reason: "hashchange",
70
+ },
71
+ ]);
72
+
73
+ stop();
74
+ setLocation("https://example.test/products/a#details");
75
+ dispatch("hashchange");
76
+ expect(events).toHaveLength(1);
77
+ });
78
+ });
package/src/router.ts ADDED
@@ -0,0 +1,92 @@
1
+ import { RouterMode } from "./types";
2
+
3
+ export type RouteChangeEvent = {
4
+ previousUrl: string;
5
+ nextUrl: string;
6
+ reason: "pushState" | "replaceState" | "popstate" | "hashchange" | "navigation";
7
+ };
8
+
9
+ type RouteObserver = (event: RouteChangeEvent) => void;
10
+
11
+ function notifyIfChanged(
12
+ lastUrlRef: { value: string },
13
+ reason: RouteChangeEvent["reason"],
14
+ observer: RouteObserver,
15
+ ): void {
16
+ const next = location.href;
17
+ if (next === lastUrlRef.value) return;
18
+ const previous = lastUrlRef.value;
19
+ lastUrlRef.value = next;
20
+ observer({ previousUrl: previous, nextUrl: next, reason });
21
+ }
22
+
23
+ function observeHistoryChanges(observer: RouteObserver, lastUrlRef: { value: string }): () => void {
24
+ const wrap = (method: "pushState" | "replaceState") => {
25
+ const original = history[method];
26
+ history[method] = function (...args: Parameters<History["pushState"]>) {
27
+ const ret = original.apply(this, args);
28
+ notifyIfChanged(lastUrlRef, method, observer);
29
+ return ret;
30
+ };
31
+ return () => {
32
+ history[method] = original;
33
+ };
34
+ };
35
+
36
+ const restorePush = wrap("pushState");
37
+ const restoreReplace = wrap("replaceState");
38
+ const popHandler = () => notifyIfChanged(lastUrlRef, "popstate", observer);
39
+ const hashHandler = () => notifyIfChanged(lastUrlRef, "hashchange", observer);
40
+
41
+ window.addEventListener("popstate", popHandler);
42
+ window.addEventListener("hashchange", hashHandler);
43
+
44
+ return () => {
45
+ restorePush();
46
+ restoreReplace();
47
+ window.removeEventListener("popstate", popHandler);
48
+ window.removeEventListener("hashchange", hashHandler);
49
+ };
50
+ }
51
+
52
+ function observeNavigationApi(observer: RouteObserver, lastUrlRef: { value: string }): () => void {
53
+ const w = globalThis as Record<string, unknown>;
54
+ const navigation = w.navigation as {
55
+ addEventListener?: (type: string, listener: () => void) => void;
56
+ removeEventListener?: (type: string, listener: () => void) => void;
57
+ } | undefined;
58
+ if (!navigation || typeof navigation.addEventListener !== "function") {
59
+ return () => {};
60
+ }
61
+
62
+ const listener = () => notifyIfChanged(lastUrlRef, "navigation", observer);
63
+ navigation.addEventListener("navigate", listener);
64
+
65
+ return () => {
66
+ if (typeof navigation.removeEventListener === "function") {
67
+ navigation.removeEventListener("navigate", listener);
68
+ }
69
+ };
70
+ }
71
+
72
+ export function observeRouteChanges(
73
+ mode: RouterMode,
74
+ observer: RouteObserver,
75
+ ): () => void {
76
+ if (mode === "none") return () => {};
77
+
78
+ const lastUrlRef = { value: location.href };
79
+ const cleanups: Array<() => void> = [];
80
+
81
+ if (mode === "navigation") {
82
+ cleanups.push(observeNavigationApi(observer, lastUrlRef));
83
+ }
84
+
85
+ if (mode === "history" || mode === "navigation") {
86
+ cleanups.push(observeHistoryChanges(observer, lastUrlRef));
87
+ }
88
+
89
+ return () => {
90
+ for (const cleanup of cleanups) cleanup();
91
+ };
92
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createApexRuntimeTools } from "./runtime-tools";
3
+
4
+ describe("createApexRuntimeTools", () => {
5
+ it("runs domLoaded immediately after the document is ready", () => {
6
+ const callback = vi.fn();
7
+ const tools = createApexRuntimeTools({
8
+ document: { readyState: "complete" } as unknown as Document,
9
+ });
10
+
11
+ const cleanup = tools.domLoaded(callback);
12
+
13
+ expect(callback).toHaveBeenCalledTimes(1);
14
+ expect(cleanup).not.toThrow();
15
+ });
16
+
17
+ it("watches dataLayer entries by object or key-value matchers", () => {
18
+ const windowLike = {
19
+ dataLayer: [{ event: "page_view", page_type: "pdp" }],
20
+ setTimeout,
21
+ clearTimeout,
22
+ } as unknown as Window & typeof globalThis;
23
+ const tools = createApexRuntimeTools({ window: windowLike });
24
+ const seen: unknown[] = [];
25
+
26
+ const cleanup = tools.waitForDataLayer(
27
+ { event: "add_to_cart" },
28
+ (entry) => seen.push(entry),
29
+ { once: false },
30
+ );
31
+ windowLike.dataLayer.push({ event: "add_to_cart", sku: "sku-1" });
32
+ windowLike.dataLayer.push({ event: "checkout_started" });
33
+ windowLike.dataLayer.push({ event: "add_to_cart", sku: "sku-2" });
34
+ cleanup();
35
+
36
+ expect(seen).toEqual([
37
+ { event: "add_to_cart", sku: "sku-1" },
38
+ { event: "add_to_cart", sku: "sku-2" },
39
+ ]);
40
+ });
41
+
42
+ it("normalizes gtag Arguments entries before matching and callback delivery", () => {
43
+ const windowLike = {
44
+ dataLayer: [],
45
+ setTimeout,
46
+ clearTimeout,
47
+ } as unknown as Window & typeof globalThis;
48
+ const tools = createApexRuntimeTools({ window: windowLike });
49
+ const seen: unknown[] = [];
50
+
51
+ tools.waitForDataLayer(
52
+ { event: "add_to_cart" },
53
+ (entry) => seen.push(entry),
54
+ { once: false },
55
+ );
56
+ (function (..._args: unknown[]) {
57
+ windowLike.dataLayer.push(arguments);
58
+ })("event", "add_to_cart", { currency: "EUR", value: 495 });
59
+ windowLike.dataLayer.push({ 0: "consent", 1: "update", 2: { event: "add_to_cart" } });
60
+
61
+ expect(seen).toEqual([{ currency: "EUR", value: 495, event: "add_to_cart" }]);
62
+ expect(Object.getPrototypeOf(seen[0])).toBe(Object.prototype);
63
+ });
64
+
65
+ it("keeps arbitrary raw-object dataLayer matching semantics", () => {
66
+ const windowLike = {
67
+ dataLayer: [{ page_type: "pdp", sku: "sku-1" }],
68
+ setTimeout,
69
+ clearTimeout,
70
+ } as unknown as Window & typeof globalThis;
71
+ const tools = createApexRuntimeTools({ window: windowLike });
72
+ const seen: unknown[] = [];
73
+
74
+ tools.waitForDataLayer({ page_type: "pdp" }, (entry) => seen.push(entry));
75
+
76
+ expect(seen).toEqual([{ page_type: "pdp", sku: "sku-1" }]);
77
+ });
78
+
79
+ it("exposes track, goals, signals, reInit, get, and redirect delegates", () => {
80
+ const track = vi.fn();
81
+ const trackGoal = vi.fn();
82
+ const signal = vi.fn();
83
+ const reInit = vi.fn(() => ["assignment"]);
84
+ const get = vi.fn((key: string) => (key === "experiments" ? [] : null));
85
+ const assign = vi.fn();
86
+ const tools = createApexRuntimeTools({
87
+ track,
88
+ trackGoal,
89
+ signal,
90
+ reInit,
91
+ get,
92
+ location: {
93
+ href: "https://store.test/products/a",
94
+ assign,
95
+ } as unknown as Location,
96
+ });
97
+
98
+ tools.track("custom", { ok: true });
99
+ tools.trackGoal("goal-1", { value: 1 });
100
+ tools.signal("visitor.vip", true, 7);
101
+ expect(tools.reInit(true)).toEqual(["assignment"]);
102
+ expect(tools.get("experiments")).toEqual([]);
103
+ tools.redirect("/cart");
104
+
105
+ expect(track).toHaveBeenCalledWith("custom", { ok: true });
106
+ expect(trackGoal).toHaveBeenCalledWith("goal-1", { value: 1 });
107
+ expect(signal).toHaveBeenCalledWith("visitor.vip", true, 7);
108
+ expect(reInit).toHaveBeenCalledWith(true);
109
+ expect(get).toHaveBeenCalledWith("experiments");
110
+ expect(assign).toHaveBeenCalledWith("https://store.test/cart");
111
+ });
112
+ });