@drip-apex/sdk 0.1.0 → 0.3.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 (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -0,0 +1,100 @@
1
+ import type { ReplaySignals } from "./replay";
2
+
3
+ export const REPLAY_MAX_CLICK_SIGNALS = 100;
4
+ export const REPLAY_MAX_NAV_SIGNALS = 50;
5
+ export const REPLAY_MAX_ERROR_SIGNALS = 20;
6
+ export const REPLAY_MAX_FORM_SIGNALS = 20;
7
+
8
+ const MAX_SELECTOR_LENGTH = 500;
9
+ const MAX_URL_LENGTH = 2_000;
10
+ const MAX_ERROR_LENGTH = 200;
11
+ const MAX_MUTATION_LATENCY_MS = 3_000;
12
+
13
+ function finiteNumber(value: unknown): value is number {
14
+ return typeof value === "number" && Number.isFinite(value);
15
+ }
16
+
17
+ function normalizeClicks(value: unknown): ReplaySignals["clicks"] {
18
+ if (!Array.isArray(value)) return [];
19
+ const normalized: ReplaySignals["clicks"] = [];
20
+ for (const item of value) {
21
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
22
+ const candidate = item as Record<string, unknown>;
23
+ if (!finiteNumber(candidate.ts) || !finiteNumber(candidate.x) || !finiteNumber(candidate.y)) continue;
24
+ if (typeof candidate.selector !== "string") continue;
25
+ const selector = candidate.selector.trim();
26
+ if (!selector || selector.length > MAX_SELECTOR_LENGTH) continue;
27
+ const mutMs = candidate.mut_ms;
28
+ if (mutMs !== undefined && (!finiteNumber(mutMs) || mutMs < 0 || mutMs > MAX_MUTATION_LATENCY_MS)) continue;
29
+ normalized.push({
30
+ ts: candidate.ts,
31
+ selector,
32
+ x: Math.round(candidate.x),
33
+ y: Math.round(candidate.y),
34
+ ...(mutMs === undefined ? {} : { mut_ms: mutMs }),
35
+ });
36
+ if (normalized.length >= REPLAY_MAX_CLICK_SIGNALS) break;
37
+ }
38
+ return normalized;
39
+ }
40
+
41
+ function normalizeNav(value: unknown): ReplaySignals["nav"] {
42
+ if (!Array.isArray(value)) return [];
43
+ const normalized: ReplaySignals["nav"] = [];
44
+ for (const item of value) {
45
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
46
+ const candidate = item as Record<string, unknown>;
47
+ if (!finiteNumber(candidate.ts) || typeof candidate.url !== "string") continue;
48
+ const url = candidate.url.trim();
49
+ if (!url || url.length > MAX_URL_LENGTH) continue;
50
+ normalized.push({ ts: candidate.ts, url });
51
+ if (normalized.length >= REPLAY_MAX_NAV_SIGNALS) break;
52
+ }
53
+ return normalized;
54
+ }
55
+
56
+ function normalizeErrors(value: unknown): ReplaySignals["errors"] {
57
+ if (!Array.isArray(value)) return [];
58
+ const normalized: ReplaySignals["errors"] = [];
59
+ for (const item of value) {
60
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
61
+ const candidate = item as Record<string, unknown>;
62
+ if (!finiteNumber(candidate.ts) || typeof candidate.message !== "string") continue;
63
+ const message = candidate.message.trim();
64
+ if (!message) continue;
65
+ normalized.push({ ts: candidate.ts, message: message.slice(0, MAX_ERROR_LENGTH) });
66
+ if (normalized.length >= REPLAY_MAX_ERROR_SIGNALS) break;
67
+ }
68
+ return normalized;
69
+ }
70
+
71
+ function normalizeForms(value: unknown): ReplaySignals["form_focus"] {
72
+ if (!Array.isArray(value)) return [];
73
+ const selectors = new Set<string>();
74
+ const normalized: ReplaySignals["form_focus"] = [];
75
+ for (const item of value) {
76
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
77
+ const candidate = item as Record<string, unknown>;
78
+ if (!finiteNumber(candidate.ts) || typeof candidate.selector !== "string") continue;
79
+ const selector = candidate.selector.trim();
80
+ if (!selector || selector.length > MAX_SELECTOR_LENGTH || selectors.has(selector)) continue;
81
+ selectors.add(selector);
82
+ normalized.push({ ts: candidate.ts, selector });
83
+ if (normalized.length >= REPLAY_MAX_FORM_SIGNALS) break;
84
+ }
85
+ return normalized;
86
+ }
87
+
88
+ export function normalizeReplaySignalState(value: Partial<Record<keyof ReplaySignals, unknown>> | undefined): ReplaySignals {
89
+ const scrollMaxPct = finiteNumber(value?.scroll_max_pct)
90
+ ? Math.max(0, Math.min(100, value.scroll_max_pct))
91
+ : 0;
92
+ return {
93
+ clicks: normalizeClicks(value?.clicks),
94
+ nav: normalizeNav(value?.nav),
95
+ errors: normalizeErrors(value?.errors),
96
+ form_focus: normalizeForms(value?.form_focus),
97
+ form_submit: normalizeForms(value?.form_submit),
98
+ scroll_max_pct: scrollMaxPct,
99
+ };
100
+ }
@@ -0,0 +1,249 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { normalizeReplaySignalState } from "./replay-signal-state";
4
+ import { createReplaySignalDeriver } from "./replay-signals";
5
+
6
+ describe("replay signal derivation", () => {
7
+ beforeEach(() => {
8
+ document.body.innerHTML = `
9
+ <form id="checkout-form">
10
+ <input id="email" />
11
+ <button id="buy" type="submit"><span data-test="buy-label">Buy</span></button>
12
+ </form>
13
+ `;
14
+ history.replaceState(null, "", "/products/example");
15
+ });
16
+
17
+ it("derives clustered clicks and mutation latency from rrweb events", () => {
18
+ const tracker = createReplaySignalDeriver();
19
+ const button = document.querySelector("#buy")!;
20
+ for (const [timestamp, x, y] of [
21
+ [1_000, 100, 100],
22
+ [1_500, 112, 110],
23
+ [2_100, 118, 116],
24
+ ]) {
25
+ tracker.accept(
26
+ { type: 3, timestamp, data: { source: 2, type: 2, x, y } },
27
+ button,
28
+ );
29
+ }
30
+ tracker.accept({ type: 3, timestamp: 2_500, data: { source: 0 } }, null);
31
+
32
+ const signals = tracker.snapshot();
33
+ expect(signals.clicks).toHaveLength(3);
34
+ expect(signals.clicks.at(-1)).toMatchObject({
35
+ selector: "button#buy",
36
+ x: 118,
37
+ y: 116,
38
+ mut_ms: 400,
39
+ });
40
+ expect(signals.clicks[0].mut_ms).toBe(1_500);
41
+ expect(signals.form_submit).toEqual([
42
+ { ts: 1_000, selector: "form#checkout-form" },
43
+ ]);
44
+ });
45
+
46
+ it("saturates session signal caps, keeps the oldest entries, and carries scroll maximum", () => {
47
+ const tracker = createReplaySignalDeriver(undefined, 50);
48
+ const button = document.querySelector("#buy")!;
49
+ const input = document.querySelector("#email")!;
50
+ Object.defineProperty(document.body, "scrollHeight", { configurable: true, value: 2_000 });
51
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
52
+
53
+ for (let index = 0; index < 105; index += 1) {
54
+ tracker.accept({
55
+ type: 3,
56
+ timestamp: index,
57
+ data: { source: 2, type: 2, x: index, y: index },
58
+ }, button);
59
+ }
60
+ for (let index = 0; index < 25; index += 1) {
61
+ tracker.accept({ type: 3, timestamp: 200 + index, data: { source: 2, type: 5 } }, input);
62
+ tracker.error(`error-${index}`, 300 + index);
63
+ }
64
+ for (let index = 0; index < 60; index += 1) {
65
+ history.pushState(null, "", `/products/${index}`);
66
+ tracker.accept({ type: 4, timestamp: 400 + index, data: { href: location.href } }, null);
67
+ }
68
+ tracker.accept({ type: 3, timestamp: 500, data: { source: 3, id: 1, y: 1_000 } }, null);
69
+ tracker.accept({ type: 3, timestamp: 600, data: { source: 3, id: 1, y: 1_500 } }, null);
70
+
71
+ const signals = tracker.snapshot();
72
+ expect(signals.clicks).toHaveLength(100);
73
+ expect(signals.clicks[0]?.x).toBe(0);
74
+ expect(signals.clicks.at(-1)?.x).toBe(99);
75
+ expect(signals.nav).toHaveLength(50);
76
+ expect(signals.nav[0]).toEqual({ ts: 50, url: "http://localhost:3000/products/example" });
77
+ expect(signals.nav.at(-1)?.url).toBe("http://localhost:3000/products/48");
78
+ expect(signals.form_submit).toHaveLength(1);
79
+ expect(signals.form_focus).toEqual([{ ts: 200, selector: "form#checkout-form" }]);
80
+ expect(signals.errors).toHaveLength(20);
81
+ expect(signals.errors.at(-1)?.message).toBe("error-19");
82
+ expect(signals.scroll_max_pct).toBe(100);
83
+ expect(tracker.snapshot()).toMatchObject({
84
+ clicks: signals.clicks,
85
+ nav: signals.nav,
86
+ errors: signals.errors,
87
+ form_focus: [{ ts: 200, selector: "form#checkout-form" }],
88
+ form_submit: [{ ts: 0, selector: "form#checkout-form" }],
89
+ scroll_max_pct: 100,
90
+ });
91
+ });
92
+
93
+ it("uses the matching stable selector attribute and finds nested submit controls", () => {
94
+ const tracker = createReplaySignalDeriver();
95
+ const label = document.querySelector("[data-test='buy-label']")!;
96
+
97
+ tracker.accept({
98
+ type: 3,
99
+ timestamp: 1_000,
100
+ data: { source: 2, type: 2, x: 10, y: 20 },
101
+ }, label);
102
+
103
+ const signals = tracker.snapshot();
104
+ expect(signals.clicks[0]?.selector).toBe('span[data-test="buy-label"]');
105
+ expect(signals.form_submit).toEqual([
106
+ { ts: 1_000, selector: "form#checkout-form" },
107
+ ]);
108
+ });
109
+
110
+ it("ignores nested-element scroll offsets and counts document scroll depth", () => {
111
+ const tracker = createReplaySignalDeriver();
112
+ Object.defineProperty(document.body, "scrollHeight", { configurable: true, value: 2_000 });
113
+ Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
114
+
115
+ tracker.accept({ type: 3, timestamp: 1_000, data: { source: 3, id: 42, y: 1_500 } }, null);
116
+ expect(tracker.snapshot().scroll_max_pct).toBe(0);
117
+
118
+ tracker.accept({ type: 3, timestamp: 1_100, data: { source: 3, id: 1, y: 500 } }, null);
119
+ expect(tracker.snapshot().scroll_max_pct).toBe(50);
120
+ });
121
+
122
+ it("updates a boundary click mutation latency in the next cumulative snapshot", () => {
123
+ const tracker = createReplaySignalDeriver(undefined, 900);
124
+ const button = document.querySelector("#buy")!;
125
+
126
+ tracker.accept({
127
+ type: 3,
128
+ timestamp: 1_000,
129
+ data: { source: 2, type: 2, x: 10, y: 20 },
130
+ }, button);
131
+
132
+ const first = tracker.snapshot(1_000);
133
+ expect(first.clicks).toEqual([{
134
+ ts: 1_000,
135
+ selector: "button#buy",
136
+ x: 10,
137
+ y: 20,
138
+ }]);
139
+
140
+ tracker.accept({ type: 3, timestamp: 1_150, data: { source: 0 } }, null);
141
+
142
+ const second = tracker.snapshot(1_150);
143
+ expect(second.clicks).toEqual([
144
+ expect.objectContaining({
145
+ ts: 1_000,
146
+ selector: "button#buy",
147
+ mut_ms: 150,
148
+ }),
149
+ ]);
150
+ expect(first.clicks[0]?.mut_ms).toBeUndefined();
151
+ });
152
+
153
+ it("returns cumulative supersets across consecutive snapshots", () => {
154
+ const tracker = createReplaySignalDeriver(undefined, 900);
155
+ const button = document.querySelector("#buy")!;
156
+
157
+ tracker.accept({
158
+ type: 3,
159
+ timestamp: 1_000,
160
+ data: { source: 2, type: 2, x: 10, y: 20 },
161
+ }, button);
162
+ tracker.error("first", 1_050);
163
+ const first = tracker.snapshot();
164
+
165
+ history.pushState(null, "", "/products/next");
166
+ tracker.accept({
167
+ type: 3,
168
+ timestamp: 2_000,
169
+ data: { source: 2, type: 2, x: 30, y: 40 },
170
+ }, button);
171
+ tracker.error("second", 2_050);
172
+ const second = tracker.snapshot();
173
+
174
+ expect(second.clicks.slice(0, first.clicks.length)).toEqual(first.clicks);
175
+ expect(second.nav.slice(0, first.nav.length)).toEqual(first.nav);
176
+ expect(second.errors.slice(0, first.errors.length)).toEqual(first.errors);
177
+ expect(second.clicks).toHaveLength(2);
178
+ expect(second.nav).toHaveLength(2);
179
+ expect(second.errors).toHaveLength(2);
180
+ });
181
+
182
+ it("seeds navigation with the entry URL and records an A-to-B-to-A u-turn", () => {
183
+ const tracker = createReplaySignalDeriver(undefined, 1_000);
184
+
185
+ history.pushState(null, "", "/products/next");
186
+ tracker.accept({ type: 4, timestamp: 2_000, data: { href: location.href } }, null);
187
+ history.pushState(null, "", "/products/example");
188
+ tracker.accept({ type: 4, timestamp: 3_000, data: { href: location.href } }, null);
189
+
190
+ expect(tracker.snapshot().nav).toEqual([
191
+ { ts: 1_000, url: "http://localhost:3000/products/example" },
192
+ { ts: 2_000, url: "http://localhost:3000/products/next" },
193
+ { ts: 3_000, url: "http://localhost:3000/products/example" },
194
+ ]);
195
+ });
196
+
197
+ it("keeps page-one form focus in later snapshots without duplicating it", () => {
198
+ const tracker = createReplaySignalDeriver({
199
+ clicks: [],
200
+ nav: [{ ts: 50, url: "https://shop.example.test/entry" }],
201
+ errors: [],
202
+ form_focus: [{ ts: 100, selector: "form#checkout-form" }],
203
+ form_submit: [],
204
+ scroll_max_pct: 0,
205
+ });
206
+ const input = document.querySelector("#email")!;
207
+
208
+ tracker.accept({ type: 3, timestamp: 200, data: { source: 2, type: 5 } }, input);
209
+
210
+ expect(tracker.snapshot()).toMatchObject({
211
+ form_focus: [{ ts: 100, selector: "form#checkout-form" }],
212
+ form_submit: [],
213
+ });
214
+ expect(tracker.snapshot()).toMatchObject({
215
+ form_focus: [{ ts: 100, selector: "form#checkout-form" }],
216
+ form_submit: [],
217
+ });
218
+ });
219
+
220
+ it("validates, deduplicates, and caps persisted signal state", () => {
221
+ const form_focus = Array.from({ length: 25 }, (_, index) => ({
222
+ ts: index,
223
+ selector: `form#${index}`,
224
+ }));
225
+ form_focus.splice(1, 0,
226
+ { ts: 50, selector: "form#0" },
227
+ { ts: Number.NaN, selector: "form#invalid" },
228
+ { ts: 51, selector: "x".repeat(501) },
229
+ );
230
+
231
+ const normalized = normalizeReplaySignalState({
232
+ clicks: [{ ts: 1, selector: " button#buy ", x: 1.2, y: 2.8 }],
233
+ nav: [{ ts: 1, url: " https://shop.example.test/entry " }],
234
+ errors: [{ ts: 1, message: "x".repeat(250) }],
235
+ form_focus,
236
+ form_submit: [{ ts: 1, selector: " " }, { ts: 2, selector: "form#valid" }],
237
+ scroll_max_pct: 120,
238
+ });
239
+
240
+ expect(normalized.clicks).toEqual([{ ts: 1, selector: "button#buy", x: 1, y: 3 }]);
241
+ expect(normalized.nav).toEqual([{ ts: 1, url: "https://shop.example.test/entry" }]);
242
+ expect(normalized.errors[0]?.message).toHaveLength(200);
243
+ expect(normalized.form_focus).toHaveLength(20);
244
+ expect(normalized.form_focus[0]).toEqual({ ts: 0, selector: "form#0" });
245
+ expect(new Set(normalized.form_focus.map(({ selector }) => selector)).size).toBe(20);
246
+ expect(normalized.form_submit).toEqual([{ ts: 2, selector: "form#valid" }]);
247
+ expect(normalized.scroll_max_pct).toBe(100);
248
+ });
249
+ });
@@ -0,0 +1,151 @@
1
+ import type { ReplaySignals } from "./replay";
2
+ import {
3
+ normalizeReplaySignalState,
4
+ REPLAY_MAX_CLICK_SIGNALS,
5
+ REPLAY_MAX_ERROR_SIGNALS,
6
+ REPLAY_MAX_FORM_SIGNALS,
7
+ REPLAY_MAX_NAV_SIGNALS,
8
+ } from "./replay-signal-state";
9
+
10
+ const EVENT_META = 4;
11
+ const EVENT_INCREMENTAL = 3;
12
+ const SOURCE_MUTATION = 0;
13
+ const SOURCE_MOUSE_INTERACTION = 2;
14
+ const SOURCE_SCROLL = 3;
15
+ const RRWEB_DOCUMENT_ID = 1;
16
+ const MOUSE_CLICK = 2;
17
+ const MOUSE_FOCUS = 5;
18
+
19
+ type RrwebEvent = {
20
+ type?: number;
21
+ timestamp?: number;
22
+ data?: Record<string, unknown>;
23
+ };
24
+
25
+ function selectorFor(element: Element): string {
26
+ const parts: string[] = [];
27
+ let current: Element | null = element;
28
+ while (current && current !== document.documentElement && parts.length < 4) {
29
+ const tag = current.tagName.toLowerCase();
30
+ const id = current.id && /^[A-Za-z][\w-]{0,80}$/.test(current.id) ? `#${current.id}` : "";
31
+ if (id) {
32
+ parts.unshift(`${tag}${id}`);
33
+ break;
34
+ }
35
+ const stableAttribute = ["data-testid", "data-test", "data-drip-id"]
36
+ .map((name) => ({ name, value: current?.getAttribute(name) }))
37
+ .find(({ value }) => value && /^[\w-]{1,80}$/.test(value));
38
+ if (stableAttribute?.value) {
39
+ parts.unshift(`${tag}[${stableAttribute.name}="${stableAttribute.value}"]`);
40
+ break;
41
+ }
42
+ let nth = 1;
43
+ let sibling = current.previousElementSibling;
44
+ while (sibling) {
45
+ if (sibling.tagName === current.tagName) nth += 1;
46
+ sibling = sibling.previousElementSibling;
47
+ }
48
+ parts.unshift(`${tag}:nth-of-type(${nth})`);
49
+ current = current.parentElement;
50
+ }
51
+ return parts.join(" > ").slice(0, 500);
52
+ }
53
+
54
+ function formFor(target: Element | null): Element | null {
55
+ if (!target) return null;
56
+ return target.tagName.toLowerCase() === "form" ? target : target.closest("form");
57
+ }
58
+
59
+ function submitTarget(target: Element | null): boolean {
60
+ if (!target) return false;
61
+ const control = target.closest("button,input");
62
+ if (!control) return false;
63
+ const tag = control.tagName.toLowerCase();
64
+ if (tag === "button") return (control.getAttribute("type") || "submit").toLowerCase() === "submit";
65
+ return tag === "input" && control.getAttribute("type")?.toLowerCase() === "submit";
66
+ }
67
+
68
+ export function createReplaySignalDeriver(signalState?: ReplaySignals, captureStartedAt?: number): {
69
+ accept: (event: RrwebEvent, target: Element | null) => void;
70
+ error: (message: string, ts?: number) => void;
71
+ snapshot: (now?: number) => ReplaySignals;
72
+ submit: (target: Element | null, ts?: number) => void;
73
+ };
74
+ export function createReplaySignalDeriver(signalState?: ReplaySignals, captureStartedAt = Date.now()) {
75
+ const signals = normalizeReplaySignalState(signalState);
76
+ const currentUrl = typeof location !== "undefined" ? location.href : "";
77
+ // Array.prototype.at is ES2022 — the SDK targets ES2018 browsers and
78
+ // esbuild does not polyfill it, so index access is required here (it also
79
+ // broke build:types declaration emit in the Commerce Lab promotion lane).
80
+ const previousUrl = signals.nav[signals.nav.length - 1]?.url ?? "";
81
+ if (currentUrl && currentUrl !== previousUrl && signals.nav.length < REPLAY_MAX_NAV_SIGNALS) {
82
+ signals.nav.push({ ts: captureStartedAt, url: currentUrl.slice(0, 2_000) });
83
+ }
84
+ let lastUrl = currentUrl || previousUrl;
85
+ const focusedForms = new Set(signals.form_focus.map((signal) => signal.selector));
86
+ const submittedForms = new Set(signals.form_submit.map((signal) => signal.selector));
87
+ const submit = (target: Element | null, ts = Date.now()) => {
88
+ const form = formFor(target);
89
+ const selector = form ? selectorFor(form) : "";
90
+ if (selector && !submittedForms.has(selector) && signals.form_submit.length < REPLAY_MAX_FORM_SIGNALS) {
91
+ submittedForms.add(selector);
92
+ signals.form_submit.push({ ts, selector });
93
+ }
94
+ };
95
+ const accept = (event: RrwebEvent, target: Element | null) => {
96
+ const ts = typeof event.timestamp === "number" ? event.timestamp : Date.now();
97
+ const currentUrl = typeof location !== "undefined" ? location.href : "";
98
+ if (currentUrl && currentUrl !== lastUrl) {
99
+ lastUrl = currentUrl;
100
+ if (signals.nav.length < REPLAY_MAX_NAV_SIGNALS) signals.nav.push({ ts, url: currentUrl.slice(0, 2_000) });
101
+ }
102
+ if (event.type === EVENT_META && typeof event.data?.href === "string" && event.data.href !== lastUrl) {
103
+ lastUrl = event.data.href;
104
+ if (signals.nav.length < REPLAY_MAX_NAV_SIGNALS) signals.nav.push({ ts, url: event.data.href.slice(0, 2_000) });
105
+ }
106
+ if (event.type !== EVENT_INCREMENTAL) return;
107
+ if (event.data?.source === SOURCE_MUTATION) {
108
+ for (const click of signals.clicks) {
109
+ const elapsed = ts - click.ts;
110
+ if (elapsed >= 0 && elapsed <= 3_000 && click.mut_ms === undefined) click.mut_ms = elapsed;
111
+ }
112
+ return;
113
+ }
114
+ if (event.data?.source === SOURCE_SCROLL) {
115
+ if (event.data.id !== RRWEB_DOCUMENT_ID) return;
116
+ const y = typeof event.data.y === "number" ? event.data.y : window.scrollY;
117
+ const height = Math.max(1, document.body?.scrollHeight ?? 0, document.documentElement?.scrollHeight ?? 0);
118
+ signals.scroll_max_pct = Math.max(signals.scroll_max_pct, Math.max(0, Math.min(100, Math.round(((y + Math.max(1, window.innerHeight)) / height) * 100))));
119
+ return;
120
+ }
121
+ if (event.data?.source !== SOURCE_MOUSE_INTERACTION || !target) return;
122
+ if (event.data.type === MOUSE_CLICK) {
123
+ if (signals.clicks.length >= REPLAY_MAX_CLICK_SIGNALS) return;
124
+ const click = {
125
+ ts,
126
+ selector: selectorFor(target),
127
+ x: Math.round(typeof event.data.x === "number" ? event.data.x : 0),
128
+ y: Math.round(typeof event.data.y === "number" ? event.data.y : 0),
129
+ };
130
+ signals.clicks.push(click);
131
+ if (submitTarget(target)) submit(target, ts);
132
+ } else if (event.data.type === MOUSE_FOCUS) {
133
+ const form = formFor(target);
134
+ if (!form || signals.form_focus.length >= REPLAY_MAX_FORM_SIGNALS) return;
135
+ const selector = selectorFor(form);
136
+ if (!focusedForms.has(selector)) {
137
+ focusedForms.add(selector);
138
+ signals.form_focus.push({ ts, selector });
139
+ }
140
+ }
141
+ };
142
+ const snapshot = (_now = Date.now()) => normalizeReplaySignalState(signals);
143
+ return {
144
+ accept,
145
+ error(message: string, ts = Date.now()) {
146
+ if (signals.errors.length < REPLAY_MAX_ERROR_SIGNALS) signals.errors.push({ ts, message: message.slice(0, 200) });
147
+ },
148
+ snapshot,
149
+ submit,
150
+ };
151
+ }