@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,41 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { readBorlabsConsent } from "./consent-evaluation";
3
+
4
+ describe("readBorlabsConsent", () => {
5
+ afterEach(() => {
6
+ delete (globalThis as Record<string, unknown>).BorlabsCookie;
7
+ if (typeof window !== "undefined") delete (window as unknown as Record<string, unknown>).BorlabsCookie;
8
+ });
9
+
10
+ it.each([true, false])("returns v3 service consent when hasConsent returns %s", (granted) => {
11
+ const hasConsent = vi.fn(() => granted);
12
+ const hasConsentForServiceGroup = vi.fn(() => false);
13
+ (globalThis as any).BorlabsCookie = { Consents: { hasConsent, hasConsentForServiceGroup } };
14
+
15
+ expect(readBorlabsConsent("apex-analytics")).toBe(granted);
16
+ expect(hasConsent).toHaveBeenCalledWith("apex-analytics");
17
+ expect(hasConsentForServiceGroup).toHaveBeenCalledTimes(granted ? 0 : 1);
18
+ });
19
+
20
+ it("falls back to the v3 service group API and defaults to statistics", () => {
21
+ const hasConsent = vi.fn(() => false);
22
+ const hasConsentForServiceGroup = vi.fn(() => true);
23
+ (globalThis as any).BorlabsCookie = { Consents: { hasConsent, hasConsentForServiceGroup } };
24
+
25
+ expect(readBorlabsConsent(" ")).toBe(true);
26
+ expect(hasConsent).toHaveBeenCalledWith("statistics");
27
+ expect(hasConsentForServiceGroup).toHaveBeenCalledWith("statistics");
28
+ });
29
+
30
+ it("falls back to the v2 API when v3 methods are unavailable", () => {
31
+ const checkCookieConsent = vi.fn(() => true);
32
+ (globalThis as any).BorlabsCookie = { checkCookieConsent };
33
+
34
+ expect(readBorlabsConsent("statistics")).toBe(true);
35
+ expect(checkCookieConsent).toHaveBeenCalledWith("statistics");
36
+ });
37
+
38
+ it("returns unknown when Borlabs is absent", () => {
39
+ expect(readBorlabsConsent()).toBeNull();
40
+ });
41
+ });
@@ -0,0 +1,50 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { readCookieScriptConsent } from "./consent-evaluation";
3
+
4
+ function installCookie(value: string): void {
5
+ Object.defineProperty(globalThis, "document", {
6
+ configurable: true,
7
+ value: { cookie: `other=1; CookieScriptConsent=${value}; trailing=1` },
8
+ });
9
+ }
10
+
11
+ describe("CookieScript consent cookie fallback", () => {
12
+ afterEach(() => {
13
+ delete (globalThis as Record<string, unknown>).CookieScript;
14
+ delete (globalThis as Record<string, unknown>).document;
15
+ });
16
+
17
+ it.each([
18
+ ["JSON-encoded categories", JSON.stringify(["functionality", "performance"])],
19
+ ["array categories", ["functionality", "performance"]],
20
+ ])("maps analytics to performance for %s", (_label, categories) => {
21
+ installCookie(encodeURIComponent(JSON.stringify({ action: "accept", categories })));
22
+ expect(readCookieScriptConsent("analytics")).toBe(true);
23
+ });
24
+
25
+ it("returns false for rejection", () => {
26
+ installCookie(encodeURIComponent(JSON.stringify({ action: "reject" })));
27
+ expect(readCookieScriptConsent("analytics")).toBe(false);
28
+ });
29
+
30
+ it("returns null before CookieScript records an action", () => {
31
+ installCookie(encodeURIComponent(JSON.stringify({ bannershown: 1 })));
32
+ expect(readCookieScriptConsent("analytics")).toBeNull();
33
+ });
34
+
35
+ it.each(["%E0%A4%A", encodeURIComponent("not json")])(
36
+ "returns null for malformed cookie %s",
37
+ (value) => {
38
+ installCookie(value);
39
+ expect(readCookieScriptConsent("analytics")).toBeNull();
40
+ },
41
+ );
42
+
43
+ it("keeps the CookieScript instance primary when it disagrees with the cookie", () => {
44
+ installCookie(encodeURIComponent(JSON.stringify({ action: "accept", categories: ["performance"] })));
45
+ (globalThis as Record<string, any>).CookieScript = {
46
+ instance: { currentState: () => ({ action: "reject" }) },
47
+ };
48
+ expect(readCookieScriptConsent("analytics")).toBe(false);
49
+ });
50
+ });
@@ -0,0 +1,151 @@
1
+ export type SynchronousConsentInput = {
2
+ requireConsent?: boolean;
3
+ hasConsent?: boolean;
4
+ consentModeEnabled?: boolean;
5
+ configuredConsentGranted?: boolean | null;
6
+ consentCookieGranted: boolean;
7
+ respectDoNotTrack?: boolean;
8
+ doNotTrackValue?: string;
9
+ };
10
+
11
+ export type ConsentProvider = "custom" | "onetrust" | "usercentrics" | "cookiebot" | "ccm19" | "pandectes" | "cookiescript" | "borlabs";
12
+
13
+ export function readPath(root: unknown, path?: string): unknown {
14
+ let current = root as Record<string, unknown> | undefined;
15
+ for (const part of (path ?? "").split(".").map((item) => item.trim()).filter(Boolean)) {
16
+ if (!current || typeof current !== "object") return undefined;
17
+ current = current[part] as Record<string, unknown> | undefined;
18
+ }
19
+ return current;
20
+ }
21
+
22
+ function tokenMatches(value: unknown, required: string): boolean {
23
+ return value != null && String(value).trim().toLowerCase() === required.trim().toLowerCase();
24
+ }
25
+
26
+ export function readConsentCookie(name: string): string {
27
+ try {
28
+ const match = document.cookie.match(new RegExp("(?:^|;)\\s*" + name + "=([^;]*)"));
29
+ return match ? decodeURIComponent(match[1]) : "";
30
+ } catch { return ""; }
31
+ }
32
+
33
+ /** The SDK's synchronous native-CMP readers, shared with the pre-SDK capture fragment. */
34
+ export function readConfiguredConsent(provider?: ConsentProvider, requiredGroup?: string, getter?: string): boolean | null {
35
+ const root = globalThis as Record<string, any>;
36
+ if (provider === "cookiescript") return readCookieScriptConsent(requiredGroup);
37
+ if (provider === "borlabs") return readBorlabsConsent(requiredGroup);
38
+ if (provider === "usercentrics") {
39
+ const uc = root.UC_UI ?? root.window?.UC_UI;
40
+ if (typeof uc?.getServicesBaseInfo !== "function") return null;
41
+ let services: unknown;
42
+ try { services = uc.getServicesBaseInfo(); } catch { return null; }
43
+ if (!Array.isArray(services)) return null;
44
+ const required = requiredGroup?.trim() || "Apex";
45
+ const service = services.find((item) => item && typeof item === "object" &&
46
+ ["name", "id", "serviceId", "templateId", "dataProcessor", "dataProcessorName"]
47
+ .some((key) => tokenMatches((item as Record<string, unknown>)[key], required))) as Record<string, any> | undefined;
48
+ return service ? service.consent?.status === true || service.consentStatus === true || service.status === true : false;
49
+ }
50
+ if (provider === "cookiebot") {
51
+ const consent = (root.Cookiebot ?? root.window?.Cookiebot)?.consent;
52
+ if (!consent || typeof consent !== "object") return null;
53
+ const aliases: Record<string, string> = { analytics: "statistics", stats: "statistics", statistic: "statistics", preference: "preferences" };
54
+ const key = aliases[(requiredGroup?.trim() || "statistics").toLowerCase()] || (requiredGroup?.trim() || "statistics").toLowerCase();
55
+ return typeof consent[key] === "boolean" ? consent[key] : false;
56
+ }
57
+ if (provider === "ccm19") {
58
+ const embeddings = (root.CCM ?? root.window?.CCM)?.acceptedEmbeddings;
59
+ if (!Array.isArray(embeddings)) return null;
60
+ const required = requiredGroup?.trim() || "Apex";
61
+ return embeddings.some((item) => item && typeof item === "object" &&
62
+ (tokenMatches((item as any).name, required) || (item as any).id === required));
63
+ }
64
+ if (provider === "pandectes") {
65
+ const purpose = (requiredGroup?.trim() || "analytics").toLowerCase();
66
+ const method = ({ analytics: "analyticsProcessingAllowed", statistics: "analyticsProcessingAllowed", statistic: "analyticsProcessingAllowed", stats: "analyticsProcessingAllowed", marketing: "marketingAllowed", preferences: "preferencesProcessingAllowed", preference: "preferencesProcessingAllowed", sale_of_data: "saleOfDataAllowed", saleofdata: "saleOfDataAllowed", "sale-of-data": "saleOfDataAllowed", sale: "saleOfDataAllowed" } as Record<string, string>)[purpose];
67
+ if (!method) return false;
68
+ for (const source of [root.Pandectes ?? root.window?.Pandectes, root.Shopify?.customerPrivacy ?? root.window?.Shopify?.customerPrivacy]) {
69
+ try { const value = source?.[method]?.(); if (typeof value === "boolean") return value; } catch {}
70
+ }
71
+ return null;
72
+ }
73
+ if (provider === "onetrust" || provider === "custom" || getter) {
74
+ const raw = readPath(root, getter ?? "OnetrustActiveGroups");
75
+ if (typeof raw === "boolean") return raw;
76
+ if (raw == null) return null;
77
+ const required = requiredGroup;
78
+ if (!required) return raw != null && String(raw).trim() !== "";
79
+ if (Array.isArray(raw)) return raw.map(String).includes(required);
80
+ if (typeof raw === "string") return raw.split(",").map((item) => item.trim()).filter(Boolean).includes(required);
81
+ return raw == null ? null : false;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ export function readBorlabsConsent(requiredGroup?: string): boolean | null {
87
+ const root = globalThis as Record<string, any>;
88
+ const borlabs = root.BorlabsCookie ?? root.window?.BorlabsCookie;
89
+ if (!borlabs) return null;
90
+ const required = requiredGroup?.trim() || "statistics";
91
+ const consents = borlabs.Consents;
92
+ try { if (typeof consents?.hasConsent === "function" && consents.hasConsent(required)) return true; } catch {}
93
+ try { if (typeof consents?.hasConsentForServiceGroup === "function") return Boolean(consents.hasConsentForServiceGroup(required)); } catch {}
94
+ try { if (typeof borlabs.checkCookieConsent === "function") return Boolean(borlabs.checkCookieConsent(required)); } catch {}
95
+ return null;
96
+ }
97
+
98
+ export function readCookieScriptConsent(requiredGroup?: string): boolean | null {
99
+ const root = globalThis as Record<string, any>;
100
+ const requested = (requiredGroup?.trim() || "analytics").toLowerCase();
101
+ const aliases: Record<string, string> = {
102
+ analytics: "performance", statistics: "performance", statistic: "performance", stats: "performance",
103
+ marketing: "targeting", advertising: "targeting", ads: "targeting", functional: "functionality",
104
+ preferences: "functionality", preference: "functionality", necessary: "strict", essential: "strict",
105
+ };
106
+ const required = aliases[requested] || requested;
107
+ const instance = root.CookieScript?.instance
108
+ ?? (root.window as Record<string, any> | undefined)?.CookieScript?.instance;
109
+ let currentState: unknown;
110
+ if (instance && typeof instance === "object" && typeof instance.currentState === "function") {
111
+ try { currentState = instance.currentState(); } catch {}
112
+ }
113
+ if (!currentState || typeof currentState !== "object" || Array.isArray(currentState)) {
114
+ try {
115
+ currentState = JSON.parse(readConsentCookie("CookieScriptConsent"));
116
+ const categories = (currentState as Record<string, unknown>).categories;
117
+ if (typeof categories === "string") (currentState as Record<string, unknown>).categories = JSON.parse(categories);
118
+ } catch { return null; }
119
+ }
120
+ if (!currentState || typeof currentState !== "object" || Array.isArray(currentState)) return null;
121
+ const consent = currentState as Record<string, unknown>;
122
+ if (consent.action === "reject") return false;
123
+ if (consent.action !== "accept") return null;
124
+ const categories = consent.categories;
125
+ if (!Array.isArray(categories)) return false;
126
+ return categories.some((item) => String(item).toLowerCase() === required);
127
+ }
128
+
129
+ export const CONSENT_REQUIRED = 1;
130
+ export const CONSENT_GRANTED = 2;
131
+ export const DNT_BLOCKED = 4;
132
+ export const STORAGE_ALLOWED = 8;
133
+
134
+ export function resolveSynchronousConsentGranted(input: SynchronousConsentInput & { provider?: ConsentProvider }): boolean {
135
+ return Boolean(evaluateSynchronousConsentForProvider(input) & CONSENT_GRANTED);
136
+ }
137
+
138
+ export function evaluateSynchronousConsentForProvider(input: SynchronousConsentInput & { provider?: ConsentProvider }): number {
139
+ const configured = input.configuredConsentGranted
140
+ ?? (input.provider === "cookiescript" ? false : null);
141
+ return evaluateSynchronousConsent({ ...input, configuredConsentGranted: configured });
142
+ }
143
+
144
+ /** Pure synchronous consent/DNT bitmask shared by SDK init and the inline bootstrap. */
145
+ export function evaluateSynchronousConsent(input: SynchronousConsentInput): number {
146
+ const required = input.requireConsent === true || typeof input.hasConsent === "boolean" || input.consentModeEnabled === true;
147
+ const granted = !required || (typeof input.hasConsent === "boolean" ? input.hasConsent : input.configuredConsentGranted ?? input.consentCookieGranted);
148
+ const value = (input.doNotTrackValue ?? "").toLowerCase();
149
+ const dnt = input.respectDoNotTrack === true && (value === "1" || value === "yes");
150
+ return (required ? CONSENT_REQUIRED : 0) | (granted ? CONSENT_GRANTED : 0) | (dnt ? DNT_BLOCKED : 0) | (granted && !dnt ? STORAGE_ALLOWED : 0);
151
+ }
@@ -0,0 +1,30 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { injectCss, removeAllCss, removeCssForOwner } from "./css";
4
+
5
+ describe("CSS artifact journal", () => {
6
+ afterEach(() => {
7
+ removeAllCss();
8
+ document.head.innerHTML = "";
9
+ });
10
+
11
+ it("replaces stale content when an existing element id carries different CSS", () => {
12
+ injectCss(".hero{color:red}", "exp", "var", ["page-a"], "artifact-a");
13
+ injectCss(".hero{color:blue}", "exp", "var", ["page-a"], "artifact-a");
14
+
15
+ expect(document.getElementById("drip-css-exp-var")?.textContent).toBe(
16
+ ".hero{color:blue}",
17
+ );
18
+ });
19
+
20
+ it("keeps a shared artifact until its final owner tears down", () => {
21
+ injectCss(".hero{color:red}", "exp", "var", ["page-a"], "artifact-a");
22
+ injectCss(".hero{color:red}", "exp", "var", ["page-b"], "artifact-a");
23
+
24
+ removeCssForOwner("page-a");
25
+ expect(document.getElementById("drip-css-exp-var")).not.toBeNull();
26
+
27
+ removeCssForOwner("page-b");
28
+ expect(document.getElementById("drip-css-exp-var")).toBeNull();
29
+ });
30
+ });
package/src/css.ts CHANGED
@@ -1,17 +1,82 @@
1
1
  const CSS_CLASS = "drip-css";
2
2
  const CSS_LINK_CLASS = "drip-css-link";
3
+ type CssJournalEntry = {
4
+ seq: number;
5
+ artifactId: string;
6
+ epoch: number;
7
+ owners: Set<string>;
8
+ target: Element;
9
+ state: "applied" | "invalidated";
10
+ };
11
+ const cssJournal: CssJournalEntry[] = [];
12
+ let cssSequence = 0;
3
13
 
4
- export function injectCss(css: string, experimentId: string, variationId: string): void {
14
+ function recordArtifact(
15
+ element: Element,
16
+ artifactId: string,
17
+ ownerKeys: readonly string[],
18
+ epoch: number,
19
+ ): void {
20
+ const existing = cssJournal.find(
21
+ (entry) => entry.target === element && entry.artifactId === artifactId,
22
+ );
23
+ if (existing) {
24
+ ownerKeys.forEach((ownerKey) => existing.owners.add(ownerKey));
25
+ existing.state = "applied";
26
+ existing.epoch = epoch;
27
+ return;
28
+ }
29
+ cssJournal.push({
30
+ seq: ++cssSequence,
31
+ artifactId,
32
+ epoch,
33
+ owners: new Set(ownerKeys),
34
+ target: element,
35
+ state: "applied",
36
+ });
37
+ }
38
+
39
+ function forgetElement(element: Element): void {
40
+ for (let index = cssJournal.length - 1; index >= 0; index -= 1) {
41
+ if (cssJournal[index].target === element) cssJournal.splice(index, 1);
42
+ }
43
+ }
44
+
45
+ export function injectCss(
46
+ css: string,
47
+ experimentId: string,
48
+ variationId: string,
49
+ ownerKeys: readonly string[] = [],
50
+ artifactId = "css:" + experimentId + ":" + variationId,
51
+ epoch = 0,
52
+ ): void {
5
53
  const id = "drip-css-" + experimentId + "-" + variationId;
6
- if (document.getElementById(id)) return;
54
+ const existing = document.getElementById(id);
55
+ if (existing && existing.textContent === css) {
56
+ recordArtifact(existing, artifactId, ownerKeys, epoch);
57
+ return;
58
+ }
59
+ if (existing) {
60
+ forgetElement(existing);
61
+ existing.textContent = css;
62
+ recordArtifact(existing, artifactId, ownerKeys, epoch);
63
+ return;
64
+ }
7
65
  const style = document.createElement("style");
8
66
  style.id = id;
9
67
  style.className = CSS_CLASS;
10
68
  style.textContent = css;
69
+ recordArtifact(style, artifactId, ownerKeys, epoch);
11
70
  (document.head || document.documentElement).appendChild(style);
12
71
  }
13
72
 
14
- export function injectExperimentCss(css: string, experimentId: string): void {
73
+ export function injectExperimentCss(
74
+ css: string,
75
+ experimentId: string,
76
+ ownerKeys: readonly string[] = [],
77
+ artifactId = "css:experiment:" + experimentId,
78
+ epoch = 0,
79
+ ): void {
15
80
  const id = "drip-css-exp-" + experimentId;
16
81
  let style = document.getElementById(id) as HTMLStyleElement | null;
17
82
  if (!style) {
@@ -20,35 +85,84 @@ export function injectExperimentCss(css: string, experimentId: string): void {
20
85
  style.className = CSS_CLASS;
21
86
  (document.head || document.documentElement).appendChild(style);
22
87
  }
88
+ if (style.textContent !== css) forgetElement(style);
23
89
  style.textContent = css;
90
+ recordArtifact(style, artifactId, ownerKeys, epoch);
24
91
  }
25
92
 
26
93
  export function removeCss(experimentId: string, variationId: string): void {
27
94
  const el = document.getElementById("drip-css-" + experimentId + "-" + variationId);
28
- if (el) el.remove();
95
+ if (el) {
96
+ forgetElement(el);
97
+ el.remove();
98
+ }
29
99
  }
30
100
 
31
- export function injectExternalCss(url: string, experimentId: string, variationId: string): Promise<void> {
101
+ export function injectExternalCss(
102
+ url: string,
103
+ experimentId: string,
104
+ variationId: string,
105
+ ownerKeys: readonly string[] = [],
106
+ artifactId = "css:external:" + experimentId + ":" + variationId,
107
+ epoch = 0,
108
+ ): Promise<void> {
32
109
  return new Promise((resolve, reject) => {
33
110
  const id = "drip-css-link-" + experimentId + "-" + variationId;
34
111
  const previous = document.getElementById(id);
35
112
  if (previous && previous.getAttribute("href") === url) {
113
+ recordArtifact(previous, artifactId, ownerKeys, epoch);
36
114
  resolve();
37
115
  return;
38
116
  }
39
- if (previous) previous.remove();
117
+ if (previous) {
118
+ forgetElement(previous);
119
+ previous.remove();
120
+ }
40
121
  const link = document.createElement("link");
41
122
  link.id = id;
42
123
  link.className = CSS_LINK_CLASS;
43
124
  link.rel = "stylesheet";
44
125
  link.href = url;
126
+ recordArtifact(link, artifactId, ownerKeys, epoch);
45
127
  link.onload = () => resolve();
46
128
  link.onerror = () => reject(new Error("Failed to load CSS artifact: " + url));
47
129
  (document.head || document.documentElement).appendChild(link);
48
130
  });
49
131
  }
50
132
 
133
+ export function removeCssForOwner(ownerKey: string, artifactId?: string): void {
134
+ const removed = new Set<Element>();
135
+ for (let index = cssJournal.length - 1; index >= 0; index -= 1) {
136
+ const entry = cssJournal[index];
137
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
138
+ if (!entry.owners.delete(ownerKey) || entry.owners.size > 0) continue;
139
+ removed.add(entry.target);
140
+ cssJournal.splice(index, 1);
141
+ }
142
+ for (const element of removed) {
143
+ if (!cssJournal.some((entry) => entry.target === element)) element.remove();
144
+ }
145
+ }
146
+
147
+ export function addCssOwners(
148
+ existingOwnerKeys: ReadonlySet<string>,
149
+ ownerKeys: readonly string[],
150
+ artifactId?: string,
151
+ ): void {
152
+ if (existingOwnerKeys.size === 0 || ownerKeys.length === 0) return;
153
+ for (const entry of cssJournal) {
154
+ if (artifactId !== undefined && entry.artifactId !== artifactId) continue;
155
+ if (
156
+ !Array.from(existingOwnerKeys).some((ownerKey) =>
157
+ entry.owners.has(ownerKey)
158
+ )
159
+ ) continue;
160
+ ownerKeys.forEach((ownerKey) => entry.owners.add(ownerKey));
161
+ }
162
+ }
163
+
51
164
  export function removeAllCss(): void {
52
165
  document.querySelectorAll("." + CSS_CLASS).forEach((el) => el.remove());
53
166
  document.querySelectorAll("." + CSS_LINK_CLASS).forEach((el) => el.remove());
167
+ cssJournal.length = 0;
54
168
  }
package/src/cwv.test.ts CHANGED
@@ -1,95 +1,78 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
2
 
3
- describe("CWV self-measurement", () => {
4
- afterEach(async () => {
5
- const { resetCwvMeasurement } = await import("./cwv");
6
- resetCwvMeasurement();
7
- vi.useRealTimers();
8
- vi.unstubAllGlobals();
9
- vi.restoreAllMocks();
3
+ // Coverage for the route-scoped CLS measurement: the total/after-reveal split,
4
+ // the hadRecentInput filter, and route-reset finalization. The full-page
5
+ // vitals (LCP/INP/TTFB + ratings) live in storefront-vitals and its suite.
6
+ describe("CWV route-scoped CLS measurement", () => {
7
+ let callbacks: PerformanceObserverCallback[];
8
+
9
+ beforeEach(() => {
10
10
  vi.resetModules();
11
+ vi.useFakeTimers();
12
+ callbacks = [];
13
+ vi.stubGlobal("PerformanceObserver", class {
14
+ constructor(callback: PerformanceObserverCallback) { callbacks.push(callback); }
15
+ observe = vi.fn();
16
+ disconnect = vi.fn();
17
+ takeRecords = vi.fn(() => []);
18
+ });
19
+ vi.stubGlobal("performance", { now: () => 1_000 });
11
20
  });
12
21
 
13
- it("feature-detects unsupported PerformanceObserver without throwing", async () => {
14
- vi.stubGlobal("PerformanceObserver", undefined);
15
- const { getCwvMetrics, startCwvMeasurement } = await import("./cwv");
16
-
17
- expect(() => startCwvMeasurement()).not.toThrow();
18
- expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
22
+ afterEach(() => {
23
+ vi.useRealTimers();
24
+ vi.unstubAllGlobals();
19
25
  });
20
26
 
21
- it("resets without constructing an observer when measurement is disabled", async () => {
22
- const observer = vi.fn();
23
- vi.stubGlobal("PerformanceObserver", observer);
24
- const { getCwvMetrics, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
25
-
26
- startCwvMeasurement(true);
27
+ const emit = (entries: unknown[]) => callbacks.at(-1)?.(
28
+ { getEntries: () => entries } as PerformanceObserverEntryList,
29
+ {} as PerformanceObserver,
30
+ );
27
31
 
28
- expect(observer).not.toHaveBeenCalled();
29
- await expect(waitForCwvMetrics()).resolves.toBeNull();
30
- expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
31
- });
32
-
33
- it("accumulates layout shifts and splits shifts after reveal", async () => {
34
- vi.useFakeTimers();
35
- let callback: PerformanceObserverCallback | null = null;
36
- const disconnect = vi.fn();
37
- const observe = vi.fn();
38
- vi.stubGlobal("PerformanceObserver", class PerformanceObserver {
39
- constructor(cb: PerformanceObserverCallback) { callback = cb; }
40
- observe = observe;
41
- disconnect = disconnect;
42
- takeRecords = vi.fn(() => []);
43
- });
44
- vi.spyOn(performance, "now").mockReturnValue(100);
45
- const { getCwvMetrics, markCwvReveal, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
32
+ it("splits CLS into total and after-reveal and ignores recent-input shifts", async () => {
33
+ const { markCwvReveal, resetCwvMeasurement, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
34
+ resetCwvMeasurement();
46
35
  startCwvMeasurement();
36
+ emit([
37
+ { startTime: 500, value: 0.05, hadRecentInput: false },
38
+ { startTime: 600, value: 1, hadRecentInput: true },
39
+ ]);
47
40
  markCwvReveal();
48
- const finalized = waitForCwvMetrics();
49
-
50
- callback?.({
51
- getEntries: () => [
52
- { startTime: 50, value: 0.1, hadRecentInput: false },
53
- { startTime: 150, value: 0.2, hadRecentInput: false },
54
- { startTime: 160, value: 0.4, hadRecentInput: true },
55
- ],
56
- } as unknown as PerformanceObserverEntryList, {} as PerformanceObserver);
57
-
58
- expect(observe).toHaveBeenCalledWith({ type: "layout-shift", buffered: true });
59
- expect(getCwvMetrics()).toEqual({ cls_total: 0.3, cls_after_reveal: 0.2 });
41
+ emit([{ startTime: 1_500, value: 0.02, hadRecentInput: false }]);
42
+ const completion = waitForCwvMetrics();
60
43
  await vi.advanceTimersByTimeAsync(5_000);
61
- expect(disconnect).toHaveBeenCalled();
62
- await expect(finalized).resolves.toEqual({ cls_total: 0.3, cls_after_reveal: 0.2 });
44
+ await expect(completion).resolves.toEqual({ cls_total: 0.07, cls_after_reveal: 0.02 });
63
45
  });
64
46
 
65
- it("finalizes the prior route and resets metrics when a new route starts", async () => {
66
- vi.useFakeTimers();
67
- const callbacks: PerformanceObserverCallback[] = [];
68
- const observes: ReturnType<typeof vi.fn>[] = [];
69
- vi.stubGlobal("PerformanceObserver", class PerformanceObserver {
70
- observe = vi.fn();
71
- disconnect = vi.fn();
72
- takeRecords = vi.fn(() => []);
73
- constructor(callback: PerformanceObserverCallback) {
74
- callbacks.push(callback);
75
- observes.push(this.observe);
76
- }
77
- });
78
- vi.spyOn(performance, "now").mockReturnValue(100);
79
- const { getCwvMetrics, markCwvReveal, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
80
-
47
+ it("finalizes the prior route when a new measurement starts and never reuses its values", async () => {
48
+ const { resetCwvMeasurement, startCwvMeasurement, getCwvMetrics, waitForCwvMetrics } = await import("./cwv");
49
+ resetCwvMeasurement();
81
50
  startCwvMeasurement();
82
- markCwvReveal();
51
+ emit([{ startTime: 100, value: 0.2, hadRecentInput: false }]);
83
52
  const firstRoute = waitForCwvMetrics();
84
- callbacks[0]?.({
85
- getEntries: () => [{ startTime: 150, value: 0.25, hadRecentInput: false }],
86
- } as unknown as PerformanceObserverEntryList, {} as PerformanceObserver);
87
-
88
53
  startCwvMeasurement();
89
-
90
- await expect(firstRoute).resolves.toEqual({ cls_total: 0.25, cls_after_reveal: 0.25 });
54
+ await expect(firstRoute).resolves.toEqual({ cls_total: 0.2, cls_after_reveal: 0 });
55
+ // The fresh route starts from zero — no carry-over of the prior window.
91
56
  expect(getCwvMetrics()).toEqual({ cls_total: 0, cls_after_reveal: 0 });
92
- expect(observes[0]).toHaveBeenCalledWith({ type: "layout-shift", buffered: true });
93
- expect(observes[1]).toHaveBeenCalledWith({ type: "layout-shift", buffered: false });
57
+ emit([{ startTime: 200, value: 0.1, hadRecentInput: false }]);
58
+ expect(getCwvMetrics()).toEqual({ cls_total: 0.1, cls_after_reveal: 0 });
59
+ });
60
+
61
+ it("resolves null without constructing observers when measurement is disabled", async () => {
62
+ const { resetCwvMeasurement, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
63
+ resetCwvMeasurement();
64
+ startCwvMeasurement(true);
65
+ expect(callbacks).toHaveLength(0);
66
+ await expect(waitForCwvMetrics()).resolves.toBeNull();
67
+ });
68
+
69
+ it("bounds an unrevealed route with the fallback timeout", async () => {
70
+ const { resetCwvMeasurement, startCwvMeasurement, waitForCwvMetrics } = await import("./cwv");
71
+ resetCwvMeasurement();
72
+ startCwvMeasurement();
73
+ emit([{ startTime: 300, value: 0.04, hadRecentInput: false }]);
74
+ const completion = waitForCwvMetrics();
75
+ await vi.advanceTimersByTimeAsync(5_000);
76
+ await expect(completion).resolves.toEqual({ cls_total: 0.04, cls_after_reveal: 0 });
94
77
  });
95
78
  });