@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.
- package/README.md +5 -0
- package/dist/bundle-report.json +584 -226
- package/dist/define.cjs +123 -0
- package/dist/define.cjs.map +7 -0
- package/dist/define.mjs +102 -0
- package/dist/define.mjs.map +7 -0
- package/dist/drip.cjs +19283 -0
- package/dist/drip.cjs.map +7 -0
- package/dist/drip.debug.js +1 -1
- package/dist/drip.debug.js.map +1 -1
- package/dist/drip.heatmap.js +1 -1
- package/dist/drip.heatmap.js.map +1 -1
- package/dist/drip.js +1 -1
- package/dist/drip.js.map +1 -1
- package/dist/drip.live.js +1 -1
- package/dist/drip.live.js.map +1 -1
- package/dist/drip.mjs +19267 -0
- package/dist/drip.mjs.map +7 -0
- package/dist/drip.replay-recorder.js +2 -0
- package/dist/drip.replay-recorder.js.map +1 -0
- package/dist/editor.js +147 -142
- package/dist/editor.js.map +4 -4
- package/dist/flags.cjs +1788 -0
- package/dist/flags.cjs.map +7 -0
- package/dist/flags.mjs +1765 -0
- package/dist/flags.mjs.map +7 -0
- package/dist/index.mjs +12 -0
- package/dist/index.mjs.map +2 -2
- package/dist/metafiles/define.cjs.meta.json +28 -0
- package/dist/metafiles/define.mjs.meta.json +32 -0
- package/dist/metafiles/drip.cjs.meta.json +3193 -0
- package/dist/metafiles/drip.debug.meta.json +2859 -574
- package/dist/metafiles/drip.heatmap.meta.json +2732 -460
- package/dist/metafiles/drip.live.meta.json +2732 -460
- package/dist/metafiles/drip.meta.json +2859 -574
- package/dist/metafiles/drip.mjs.meta.json +3235 -0
- package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
- package/dist/metafiles/editor.meta.json +67 -27
- package/dist/metafiles/flags.cjs.meta.json +220 -0
- package/dist/metafiles/flags.mjs.meta.json +225 -0
- package/dist/metafiles/index.meta.json +2717 -491
- package/dist/metafiles/index.mjs.meta.json +14 -4
- package/dist/react/index.js +16636 -13533
- package/dist/react/index.js.map +4 -4
- package/dist/sri.json +20 -15
- package/dist/types/activation-runtime.d.ts +91 -0
- package/dist/types/analytics-runtime.d.ts +166 -0
- package/dist/types/antiflicker.d.ts +9 -3
- package/dist/types/assignment-cookie-sync.d.ts +31 -0
- package/dist/types/assignment-runtime.d.ts +257 -0
- package/dist/types/assignment-store.d.ts +4 -8
- package/dist/types/bucket.d.ts +1 -2
- package/dist/types/compact-store.d.ts +1 -0
- package/dist/types/config-runtime.d.ts +313 -0
- package/dist/types/consent-evaluation.d.ts +28 -0
- package/dist/types/css.d.ts +5 -3
- package/dist/types/debug-loader.d.ts +2 -0
- package/dist/types/decide-assignments.d.ts +31 -0
- package/dist/types/delivery-timing.d.ts +8 -0
- package/dist/types/early-interaction-capture.d.ts +79 -0
- package/dist/types/early-reassert.d.ts +32 -0
- package/dist/types/edge-runtime.d.ts +11 -0
- package/dist/types/editor/api.d.ts +3 -0
- package/dist/types/editor/controller.d.ts +3 -1
- package/dist/types/editor/publication-result.d.ts +11 -0
- package/dist/types/editor/types.d.ts +2 -1
- package/dist/types/editor/view-kit.d.ts +1 -1
- package/dist/types/endpoint.d.ts +8 -0
- package/dist/types/exclusion-groups.d.ts +4 -7
- package/dist/types/flags.d.ts +2 -0
- package/dist/types/goal-eligibility.d.ts +4 -0
- package/dist/types/goals.d.ts +10 -1
- package/dist/types/guards.d.ts +7 -7
- package/dist/types/handover.d.ts +12 -1
- package/dist/types/hash.d.ts +8 -4
- package/dist/types/hydration.d.ts +2 -1
- package/dist/types/index.d.ts +24 -128
- package/dist/types/init-test-helpers.d.ts +48 -0
- package/dist/types/inline-bootstrap.d.ts +26 -0
- package/dist/types/js.d.ts +3 -1
- package/dist/types/money.d.ts +12 -0
- package/dist/types/mutate.d.ts +45 -6
- package/dist/types/performance.d.ts +2 -0
- package/dist/types/persistence-decoders.d.ts +12 -0
- package/dist/types/public-api.d.ts +209 -0
- package/dist/types/qa-assistant-state.d.ts +34 -0
- package/dist/types/react/index.d.ts +28 -3
- package/dist/types/reassert-sentinel.d.ts +6 -0
- package/dist/types/redirect.d.ts +48 -0
- package/dist/types/replay-recorder-entry.d.ts +1 -0
- package/dist/types/replay-signal-state.d.ts +6 -0
- package/dist/types/replay-signals.d.ts +13 -0
- package/dist/types/replay.d.ts +110 -0
- package/dist/types/router.d.ts +12 -2
- package/dist/types/runtime-state.d.ts +166 -0
- package/dist/types/sanitize.d.ts +1 -0
- package/dist/types/shadow.d.ts +3 -0
- package/dist/types/shopify-cart.d.ts +25 -1
- package/dist/types/storefront-vitals.d.ts +24 -0
- package/dist/types/targeting-diagnostics.d.ts +1 -0
- package/dist/types/targeting.d.ts +16 -2
- package/dist/types/track.d.ts +15 -1
- package/dist/types/types.d.ts +115 -9
- package/dist/types/wait.d.ts +14 -1
- package/dist/types/weekday-schedule.d.ts +3 -0
- package/package.json +17 -8
- package/src/activation-runtime.ts +2737 -0
- package/src/analytics-runtime.ts +2175 -0
- package/src/antiflicker.test.ts +216 -44
- package/src/antiflicker.ts +103 -28
- package/src/artifact-concurrency.test.ts +1091 -0
- package/src/assignment-cookie-sync.test.ts +202 -0
- package/src/assignment-cookie-sync.ts +189 -0
- package/src/assignment-runtime.ts +3204 -0
- package/src/assignment-store.test.ts +56 -0
- package/src/assignment-store.ts +7 -38
- package/src/bucket.ts +3 -18
- package/src/commerce-analytics-lifecycle.test.ts +75 -15
- package/src/commerce-config-lifecycle.test.ts +81 -1
- package/src/commerce-price-preserve.test.ts +133 -0
- package/src/commerce-price.test.ts +97 -0
- package/src/commerce-price.ts +74 -20
- package/src/commerce-shipping.test.ts +53 -0
- package/src/commerce-shipping.ts +5 -11
- package/src/compact-store.ts +15 -0
- package/src/config-fetch.test.ts +480 -9
- package/src/config-runtime.ts +2071 -0
- package/src/consent-evaluation-borlabs.test.ts +41 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +151 -0
- package/src/css.test.ts +30 -0
- package/src/css.ts +120 -6
- package/src/cwv.test.ts +60 -77
- package/src/debug-loader.test.ts +214 -0
- package/src/debug-loader.ts +41 -0
- package/src/decide-assignments.test.ts +196 -0
- package/src/decide-assignments.ts +204 -0
- package/src/delivery-timing.test.ts +104 -0
- package/src/delivery-timing.ts +89 -0
- package/src/early-interaction-capture.test.ts +451 -0
- package/src/early-interaction-capture.ts +230 -0
- package/src/early-reassert.test.ts +207 -0
- package/src/early-reassert.ts +152 -0
- package/src/edge-adoption.test.ts +435 -0
- package/src/edge-runtime.ts +30 -0
- package/src/editor/api.test.ts +109 -0
- package/src/editor/api.ts +34 -3
- package/src/editor/controller.test.ts +297 -14
- package/src/editor/controller.ts +69 -5
- package/src/editor/index.test.ts +2 -0
- package/src/editor/index.ts +1 -0
- package/src/editor/publication-result.ts +58 -0
- package/src/editor/types.ts +10 -1
- package/src/editor/view-kit.ts +2 -1
- package/src/editor/view.test.ts +108 -0
- package/src/editor/view.ts +49 -7
- package/src/endpoint.test.ts +15 -0
- package/src/endpoint.ts +10 -0
- package/src/exclusion-groups.ts +8 -18
- package/src/flags.test.ts +88 -1
- package/src/flags.ts +29 -29
- package/src/global-runtime-reconciler.test.ts +271 -0
- package/src/goal-eligibility.test.ts +29 -0
- package/src/goal-eligibility.ts +17 -0
- package/src/goals.test.ts +144 -1
- package/src/goals.ts +97 -18
- package/src/guards.test.ts +99 -1
- package/src/guards.ts +34 -15
- package/src/handover-cache-order.test.ts +6 -3
- package/src/handover.test.ts +154 -8
- package/src/handover.ts +122 -12
- package/src/hash.ts +29 -54
- package/src/holdout.test.ts +26 -0
- package/src/hydration.test.ts +73 -2
- package/src/hydration.ts +18 -9
- package/src/index.ts +130 -7287
- package/src/init-analytics.test.ts +1694 -0
- package/src/init-anti-flicker.test.ts +1211 -0
- package/src/init-cart-attributes.test.ts +2003 -0
- package/src/init-config-resolution.test.ts +999 -0
- package/src/init-consent.test.ts +971 -0
- package/src/init-empty.test.ts +11 -5406
- package/src/init-integrations.test.ts +846 -0
- package/src/init-page-activation.test.ts +5064 -0
- package/src/init-qa-loading.test.ts +740 -0
- package/src/init-redirects.test.ts +2156 -0
- package/src/init-test-helpers.ts +287 -0
- package/src/inline-bootstrap.test.ts +184 -0
- package/src/inline-bootstrap.ts +73 -0
- package/src/js.test.ts +51 -1
- package/src/js.ts +86 -2
- package/src/money.test.ts +55 -1
- package/src/money.ts +104 -2
- package/src/money.web-parity.contract.test.ts +85 -0
- package/src/mutate.test.ts +474 -10
- package/src/mutate.ts +739 -230
- package/src/page-trigger.test.ts +218 -8
- package/src/performance.ts +10 -0
- package/src/persistence-decoders.ts +18 -0
- package/src/public-api.ts +1828 -0
- package/src/qa-assistant-state.test.ts +249 -3
- package/src/qa-assistant-state.ts +146 -6
- package/src/qa-assistant-view.test.ts +220 -2
- package/src/qa-assistant-view.ts +102 -5
- package/src/react/index.test.tsx +37 -0
- package/src/react/index.tsx +19 -3
- package/src/reassert-sentinel.test.ts +155 -1
- package/src/reassert-sentinel.ts +168 -8
- package/src/redirect.test.ts +541 -0
- package/src/redirect.ts +572 -0
- package/src/replay-recorder-entry.ts +115 -0
- package/src/replay-signal-state.ts +100 -0
- package/src/replay-signals.test.ts +249 -0
- package/src/replay-signals.ts +151 -0
- package/src/replay.test.ts +764 -0
- package/src/replay.ts +514 -0
- package/src/router.test.ts +61 -2
- package/src/router.ts +50 -12
- package/src/runtime-state.ts +507 -0
- package/src/runtime.test.ts +174 -7
- package/src/runtime.ts +88 -14
- package/src/sanitize.test.ts +34 -0
- package/src/sanitize.ts +23 -12
- package/src/shadow.test.ts +187 -0
- package/src/shadow.ts +99 -0
- package/src/shopify-cart.test.ts +733 -23
- package/src/shopify-cart.ts +402 -49
- package/src/storefront-vitals.test.ts +217 -0
- package/src/storefront-vitals.ts +206 -0
- package/src/surface-signals.ts +6 -0
- package/src/targeting-conformance.test.ts +9 -0
- package/src/targeting-diagnostics.ts +38 -8
- package/src/targeting-parity-corpus.json +559 -0
- package/src/targeting-parity-corpus.test.ts +18 -0
- package/src/targeting.detailed.test.ts +105 -0
- package/src/targeting.test.ts +211 -0
- package/src/targeting.ts +88 -207
- package/src/track.test.ts +147 -0
- package/src/track.ts +125 -26
- package/src/types.ts +120 -13
- package/src/wait.test.ts +23 -0
- package/src/wait.ts +129 -13
- package/src/weekday-delivery.test.ts +56 -0
- package/src/weekday-schedule-parity-corpus.json +92 -0
- package/src/weekday-schedule.test.ts +69 -0
- package/src/weekday-schedule.ts +20 -0
|
@@ -0,0 +1,2003 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
RECENT_MONDAY_NOON, documentListeners, windowListeners, appendedAntiFlickerStyle, configPayloadHash, flushDynamicImports, installCookieJar, installMutationObserverStub, makeClassList, makeStorage, readCookieValue, readDecodedDripContext, registerInitTestLifecycle, verifiedHandoverResponse,
|
|
4
|
+
} from "./init-test-helpers";
|
|
5
|
+
|
|
6
|
+
describe("init cart attributes", () => {
|
|
7
|
+
registerInitTestLifecycle();
|
|
8
|
+
|
|
9
|
+
it("announces when the signed attribution envelope becomes available", async () => {
|
|
10
|
+
const { __test, init } = await import("./index");
|
|
11
|
+
init({
|
|
12
|
+
antiFlickerMode: "off",
|
|
13
|
+
endpoint: "https://events.test",
|
|
14
|
+
experiments: [{
|
|
15
|
+
id: "exp_envelope",
|
|
16
|
+
status: "running",
|
|
17
|
+
trafficAllocation: 1,
|
|
18
|
+
variations: [{ id: "var_envelope", weight: 1, mutations: [] }],
|
|
19
|
+
}],
|
|
20
|
+
shopId: "shop_123",
|
|
21
|
+
trackingStart: "immediate",
|
|
22
|
+
userId: "visitor_123",
|
|
23
|
+
});
|
|
24
|
+
const input = __test!.buildActiveCartAttributeSyncInput(true);
|
|
25
|
+
|
|
26
|
+
input!.onAttributionEnvelope!("signed-envelope");
|
|
27
|
+
|
|
28
|
+
expect(document.dispatchEvent).toHaveBeenCalledWith(
|
|
29
|
+
expect.objectContaining({ type: "apex:attribution-envelope" }),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
it("stamps the first active assignment onto Shopify cart attributes", async () => {
|
|
35
|
+
(globalThis as any).window.Shopify = {};
|
|
36
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
37
|
+
const { init } = await import("./index");
|
|
38
|
+
|
|
39
|
+
init({
|
|
40
|
+
antiFlickerMode: "off",
|
|
41
|
+
endpoint: "https://events.test",
|
|
42
|
+
experiments: [
|
|
43
|
+
{
|
|
44
|
+
id: "exp_first",
|
|
45
|
+
status: "running",
|
|
46
|
+
trafficAllocation: 1,
|
|
47
|
+
variations: [{ id: "var_first", weight: 1, mutations: [] }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "exp_second",
|
|
51
|
+
status: "running",
|
|
52
|
+
trafficAllocation: 1,
|
|
53
|
+
variations: [{ id: "var_second", weight: 1, mutations: [] }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
shopId: "shop_123",
|
|
57
|
+
trackingStart: "immediate",
|
|
58
|
+
userId: "visitor_123",
|
|
59
|
+
});
|
|
60
|
+
await Promise.resolve();
|
|
61
|
+
|
|
62
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
63
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/cart/update.js");
|
|
65
|
+
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
|
66
|
+
expect(body.attributes).toMatchObject({
|
|
67
|
+
apex_experiment_id: "exp_first",
|
|
68
|
+
apex_variation_id: "var_first",
|
|
69
|
+
apex_visitor_id: "visitor_123",
|
|
70
|
+
});
|
|
71
|
+
expect(body.attributes.apex_session_id).toEqual(expect.any(String));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
it("does not write active assignments when an external integration owns cart attributes", async () => {
|
|
76
|
+
(globalThis as any).window.Shopify = {};
|
|
77
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
78
|
+
const { getCartAttributes, init } = await import("./index");
|
|
79
|
+
|
|
80
|
+
init({
|
|
81
|
+
antiFlickerMode: "off",
|
|
82
|
+
cartAttributeScope: "all",
|
|
83
|
+
cartAttributeWrite: "external",
|
|
84
|
+
consentMode: { enabled: true },
|
|
85
|
+
endpoint: "https://events.test",
|
|
86
|
+
experiments: [{
|
|
87
|
+
id: "exp_external_cart",
|
|
88
|
+
status: "running",
|
|
89
|
+
trafficAllocation: 1,
|
|
90
|
+
variations: [{ id: "var_external_cart", weight: 1, mutations: [] }],
|
|
91
|
+
}],
|
|
92
|
+
hasConsent: true,
|
|
93
|
+
shopId: "shop_123",
|
|
94
|
+
trackingStart: "immediate",
|
|
95
|
+
userId: "visitor_external_cart",
|
|
96
|
+
});
|
|
97
|
+
await flushDynamicImports();
|
|
98
|
+
|
|
99
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) => url === "/cart/update.js"))
|
|
100
|
+
.toBe(false);
|
|
101
|
+
expect(getCartAttributes()).toEqual(expect.arrayContaining([
|
|
102
|
+
{ key: "apex_experiment_id", value: "exp_external_cart" },
|
|
103
|
+
{ key: "apex_variation_id", value: "var_external_cart" },
|
|
104
|
+
{ key: "apex_visitor_id", value: "visitor_external_cart" },
|
|
105
|
+
{ key: "apex_assignment_authorization", value: "" },
|
|
106
|
+
]));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
it("returns only arm labels for an external all-scope declined visitor", async () => {
|
|
111
|
+
const { getCartAttributes, init } = await import("./index");
|
|
112
|
+
|
|
113
|
+
init({
|
|
114
|
+
antiFlickerMode: "off",
|
|
115
|
+
cartAttributeScope: "all",
|
|
116
|
+
cartAttributeWrite: "external",
|
|
117
|
+
consentMode: { enabled: true },
|
|
118
|
+
endpoint: "https://events.test",
|
|
119
|
+
experiments: [{
|
|
120
|
+
id: "exp_external_all",
|
|
121
|
+
status: "running",
|
|
122
|
+
trafficAllocation: 1,
|
|
123
|
+
variations: [{ id: "var_external_all", weight: 1, mutations: [] }],
|
|
124
|
+
}],
|
|
125
|
+
hasConsent: false,
|
|
126
|
+
shopId: "shop_123",
|
|
127
|
+
trackingStart: "immediate",
|
|
128
|
+
userId: "visitor_external_all",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const attributes = getCartAttributes();
|
|
132
|
+
expect(attributes).toEqual([{
|
|
133
|
+
key: "apex_assignments",
|
|
134
|
+
value: JSON.stringify([{ e: "exp_external_all", v: "var_external_all", ep: 1 }]),
|
|
135
|
+
}]);
|
|
136
|
+
expect(attributes.map(({ key }) => key)).not.toEqual(expect.arrayContaining([
|
|
137
|
+
"apex_visitor_id",
|
|
138
|
+
"apex_session_id",
|
|
139
|
+
"apex_assignment_authorization",
|
|
140
|
+
]));
|
|
141
|
+
expect(localStorage.getItem("drip_assignments_v1")).toBeNull();
|
|
142
|
+
expect(readCookieValue("drip_assignments_v1")).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
it("keeps session-activated arm labels after SPA page deactivation and replaces a later epoch", async () => {
|
|
147
|
+
const triggeredExperiment = {
|
|
148
|
+
id: "exp_external_triggered",
|
|
149
|
+
status: "running" as const,
|
|
150
|
+
targeting: { url: [{ type: "simple" as const, pattern: "/products/*" }] },
|
|
151
|
+
trafficAllocation: 1,
|
|
152
|
+
assignmentEpoch: 1,
|
|
153
|
+
variations: [{ id: "var_external_triggered", weight: 1, mutations: [] }],
|
|
154
|
+
};
|
|
155
|
+
Object.assign((globalThis as any).location, {
|
|
156
|
+
href: "https://plain.test/products/example",
|
|
157
|
+
pathname: "/products/example",
|
|
158
|
+
});
|
|
159
|
+
const { getAssignments, getCartAttributes, init, reInit } = await import("./index");
|
|
160
|
+
|
|
161
|
+
init({
|
|
162
|
+
antiFlickerMode: "off",
|
|
163
|
+
cartAttributeScope: "all",
|
|
164
|
+
cartAttributeWrite: "external",
|
|
165
|
+
consentMode: { enabled: true },
|
|
166
|
+
experiments: [{
|
|
167
|
+
id: "exp_external_global",
|
|
168
|
+
status: "running",
|
|
169
|
+
trafficAllocation: 1,
|
|
170
|
+
variations: [{ id: "var_external_global", weight: 1, mutations: [] }],
|
|
171
|
+
}, triggeredExperiment],
|
|
172
|
+
hasConsent: false,
|
|
173
|
+
shopId: "shop_123",
|
|
174
|
+
spa: { routerMode: "history" },
|
|
175
|
+
trackingStart: "immediate",
|
|
176
|
+
userId: "visitor_external_session_ledger",
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
Object.assign((globalThis as any).location, {
|
|
180
|
+
href: "https://plain.test/cart",
|
|
181
|
+
pathname: "/cart",
|
|
182
|
+
});
|
|
183
|
+
(globalThis as any).history.pushState({}, "", "/cart");
|
|
184
|
+
expect(getAssignments().map(({ experimentId }) => experimentId)).toEqual([
|
|
185
|
+
"exp_external_global",
|
|
186
|
+
]);
|
|
187
|
+
expect(JSON.parse(getCartAttributes()[0]!.value)).toEqual([
|
|
188
|
+
{ e: "exp_external_global", v: "var_external_global", ep: 1 },
|
|
189
|
+
{ e: "exp_external_triggered", v: "var_external_triggered", ep: 1 },
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
triggeredExperiment.assignmentEpoch = 2;
|
|
193
|
+
Object.assign((globalThis as any).location, {
|
|
194
|
+
href: "https://plain.test/products/again",
|
|
195
|
+
pathname: "/products/again",
|
|
196
|
+
});
|
|
197
|
+
reInit();
|
|
198
|
+
Object.assign((globalThis as any).location, {
|
|
199
|
+
href: "https://plain.test/cart",
|
|
200
|
+
pathname: "/cart",
|
|
201
|
+
});
|
|
202
|
+
reInit();
|
|
203
|
+
expect(JSON.parse(getCartAttributes()[0]!.value)).toEqual([
|
|
204
|
+
{ e: "exp_external_global", v: "var_external_global", ep: 1 },
|
|
205
|
+
{ e: "exp_external_triggered", v: "var_external_triggered", ep: 2 },
|
|
206
|
+
]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
it("re-checks contamination for session-ledger cart assignments", async () => {
|
|
211
|
+
(globalThis as any).document.querySelectorAll = vi.fn(() => []);
|
|
212
|
+
const contaminatedExperiment = {
|
|
213
|
+
id: "exp_external_contaminated",
|
|
214
|
+
status: "running" as const,
|
|
215
|
+
targeting: { url: [{ type: "simple" as const, pattern: "/products/*" }] },
|
|
216
|
+
trafficAllocation: 1,
|
|
217
|
+
variations: [{
|
|
218
|
+
id: "var_external_contaminated",
|
|
219
|
+
weight: 1,
|
|
220
|
+
mutations: [] as Array<{ selector: string; action: "text"; text: string }>,
|
|
221
|
+
}],
|
|
222
|
+
};
|
|
223
|
+
Object.assign((globalThis as any).location, {
|
|
224
|
+
href: "https://plain.test/products/example",
|
|
225
|
+
pathname: "/products/example",
|
|
226
|
+
});
|
|
227
|
+
const { flush, getCartAttributes, init, reInit } = await import("./index");
|
|
228
|
+
init({
|
|
229
|
+
antiFlickerMode: "off",
|
|
230
|
+
cartAttributeScope: "all",
|
|
231
|
+
cartAttributeWrite: "external",
|
|
232
|
+
consentMode: { enabled: true },
|
|
233
|
+
experiments: [contaminatedExperiment],
|
|
234
|
+
hasConsent: false,
|
|
235
|
+
shopId: "shop_123",
|
|
236
|
+
spa: { activationMode: "manual" },
|
|
237
|
+
trackingStart: "immediate",
|
|
238
|
+
userId: "visitor_external_contaminated",
|
|
239
|
+
});
|
|
240
|
+
expect(getCartAttributes()).toHaveLength(1);
|
|
241
|
+
|
|
242
|
+
contaminatedExperiment.variations[0]!.mutations.push({
|
|
243
|
+
selector: "#missing",
|
|
244
|
+
action: "text",
|
|
245
|
+
text: "Missing",
|
|
246
|
+
});
|
|
247
|
+
reInit(true);
|
|
248
|
+
await flush();
|
|
249
|
+
expect(getCartAttributes()).toEqual([]);
|
|
250
|
+
// Eviction happens at contamination time, so a later re-init pruning the
|
|
251
|
+
// contamination marker cannot resurrect the entry fail-open.
|
|
252
|
+
const { __test: internals } = await import("./index");
|
|
253
|
+
expect(internals!.attributionLedgerHas("exp_external_contaminated")).toBe(false);
|
|
254
|
+
Object.assign((globalThis as any).location, {
|
|
255
|
+
href: "https://plain.test/cart",
|
|
256
|
+
pathname: "/cart",
|
|
257
|
+
});
|
|
258
|
+
reInit();
|
|
259
|
+
expect(getCartAttributes()).toEqual([]);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
it("does not wipe the session ledger on a guarded no-op init", async () => {
|
|
264
|
+
(globalThis as any).document.querySelectorAll = vi.fn(() => []);
|
|
265
|
+
const triggeredExperiment = {
|
|
266
|
+
id: "exp_guarded_noop",
|
|
267
|
+
status: "running" as const,
|
|
268
|
+
targeting: { url: [{ type: "simple" as const, pattern: "/products/*" }] },
|
|
269
|
+
trafficAllocation: 1,
|
|
270
|
+
variations: [{ id: "var_guarded_noop", weight: 1, mutations: [] }],
|
|
271
|
+
};
|
|
272
|
+
Object.assign((globalThis as any).location, {
|
|
273
|
+
href: "https://plain.test/products/example",
|
|
274
|
+
pathname: "/products/example",
|
|
275
|
+
});
|
|
276
|
+
const { getCartAttributes, init, reInit } = await import("./index");
|
|
277
|
+
const config = {
|
|
278
|
+
antiFlickerMode: "off" as const,
|
|
279
|
+
cartAttributeScope: "all" as const,
|
|
280
|
+
cartAttributeWrite: "external" as const,
|
|
281
|
+
consentMode: { enabled: true },
|
|
282
|
+
experiments: [triggeredExperiment],
|
|
283
|
+
hasConsent: false,
|
|
284
|
+
shopId: "shop_123",
|
|
285
|
+
spa: { activationMode: "manual" as const },
|
|
286
|
+
trackingStart: "immediate" as const,
|
|
287
|
+
userId: "visitor_guarded_noop",
|
|
288
|
+
};
|
|
289
|
+
init(config);
|
|
290
|
+
expect(getCartAttributes()).toHaveLength(1);
|
|
291
|
+
|
|
292
|
+
// A guarded init (edge mode) returns before committing a re-init and must
|
|
293
|
+
// not clear the session ledger.
|
|
294
|
+
init({ ...config, executionMode: "edge" as const });
|
|
295
|
+
|
|
296
|
+
Object.assign((globalThis as any).location, {
|
|
297
|
+
href: "https://plain.test/cart",
|
|
298
|
+
pathname: "/cart",
|
|
299
|
+
});
|
|
300
|
+
reInit();
|
|
301
|
+
const label = getCartAttributes();
|
|
302
|
+
expect(label).toHaveLength(1);
|
|
303
|
+
expect(label[0]!.value).toContain("exp_guarded_noop");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
it("keeps the consented cart authorization builder isolated from session-ledger entries", async () => {
|
|
308
|
+
Object.assign((globalThis as any).location, {
|
|
309
|
+
href: "https://plain.test/products/example",
|
|
310
|
+
pathname: "/products/example",
|
|
311
|
+
});
|
|
312
|
+
const { __test, init, reInit } = await import("./index");
|
|
313
|
+
init({
|
|
314
|
+
antiFlickerMode: "off",
|
|
315
|
+
consentMode: { enabled: true },
|
|
316
|
+
experiments: [{
|
|
317
|
+
id: "exp_consented_global",
|
|
318
|
+
status: "running",
|
|
319
|
+
trafficAllocation: 1,
|
|
320
|
+
variations: [{ id: "var_consented_global", weight: 1, mutations: [] }],
|
|
321
|
+
}, {
|
|
322
|
+
id: "exp_consented_triggered",
|
|
323
|
+
status: "running",
|
|
324
|
+
targeting: { url: [{ type: "simple", pattern: "/products/*" }] },
|
|
325
|
+
trafficAllocation: 1,
|
|
326
|
+
variations: [{ id: "var_consented_triggered", weight: 1, mutations: [] }],
|
|
327
|
+
}],
|
|
328
|
+
hasConsent: true,
|
|
329
|
+
shopId: "shop_123",
|
|
330
|
+
trackingStart: "immediate",
|
|
331
|
+
userId: "visitor_consented_session_ledger",
|
|
332
|
+
});
|
|
333
|
+
Object.assign((globalThis as any).location, {
|
|
334
|
+
href: "https://plain.test/cart",
|
|
335
|
+
pathname: "/cart",
|
|
336
|
+
});
|
|
337
|
+
reInit();
|
|
338
|
+
|
|
339
|
+
expect(__test!.buildActiveCartAttributeSyncInput(true)?.assignments).toEqual([
|
|
340
|
+
{
|
|
341
|
+
experimentId: "exp_consented_global",
|
|
342
|
+
variationId: "var_consented_global",
|
|
343
|
+
pageActive: true,
|
|
344
|
+
assignmentEpoch: 1,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
experimentId: "exp_consented_triggered",
|
|
348
|
+
variationId: "var_consented_triggered",
|
|
349
|
+
pageActive: false,
|
|
350
|
+
assignmentEpoch: 1,
|
|
351
|
+
},
|
|
352
|
+
]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
it("keeps the session label when a duplicate init no-ops", async () => {
|
|
357
|
+
Object.assign((globalThis as any).location, {
|
|
358
|
+
href: "https://plain.test/products/example",
|
|
359
|
+
pathname: "/products/example",
|
|
360
|
+
});
|
|
361
|
+
const { getCartAttributes, init, reInit } = await import("./index");
|
|
362
|
+
const baseConfig = {
|
|
363
|
+
antiFlickerMode: "off" as const,
|
|
364
|
+
cartAttributeScope: "all" as const,
|
|
365
|
+
cartAttributeWrite: "external" as const,
|
|
366
|
+
consentMode: { enabled: true },
|
|
367
|
+
hasConsent: false,
|
|
368
|
+
shopId: "shop_123",
|
|
369
|
+
trackingStart: "immediate" as const,
|
|
370
|
+
userId: "visitor_external_reinit",
|
|
371
|
+
};
|
|
372
|
+
init({ ...baseConfig, experiments: [{
|
|
373
|
+
id: "exp_external_reinit",
|
|
374
|
+
status: "running",
|
|
375
|
+
targeting: { url: [{ type: "simple", pattern: "/products/*" }] },
|
|
376
|
+
trafficAllocation: 1,
|
|
377
|
+
variations: [{ id: "var_external_reinit", weight: 1, mutations: [] }],
|
|
378
|
+
}] });
|
|
379
|
+
Object.assign((globalThis as any).location, {
|
|
380
|
+
href: "https://plain.test/cart",
|
|
381
|
+
pathname: "/cart",
|
|
382
|
+
});
|
|
383
|
+
reInit();
|
|
384
|
+
expect(getCartAttributes()).toHaveLength(1);
|
|
385
|
+
|
|
386
|
+
// A second init in the same JS context is a duplicate no-op (global boot
|
|
387
|
+
// flag) and must not wipe attribution the session already earned. A
|
|
388
|
+
// committed re-init only happens in a fresh context, where the ledger is
|
|
389
|
+
// a new empty Map by construction.
|
|
390
|
+
init({ ...baseConfig, experiments: [] });
|
|
391
|
+
expect(getCartAttributes()).toHaveLength(1);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
it("clears stale Apex identity while merging external all-scope declined cart labels", async () => {
|
|
396
|
+
const { init, mergeCartAttributes } = await import("./index");
|
|
397
|
+
|
|
398
|
+
init({
|
|
399
|
+
antiFlickerMode: "off",
|
|
400
|
+
cartAttributeScope: "all",
|
|
401
|
+
cartAttributeWrite: "external",
|
|
402
|
+
consentMode: { enabled: true },
|
|
403
|
+
endpoint: "https://events.test",
|
|
404
|
+
experiments: [{
|
|
405
|
+
id: "exp_external_merge",
|
|
406
|
+
status: "running",
|
|
407
|
+
trafficAllocation: 1,
|
|
408
|
+
variations: [{ id: "var_external_merge", weight: 1, mutations: [] }],
|
|
409
|
+
}],
|
|
410
|
+
hasConsent: false,
|
|
411
|
+
shopId: "shop_123",
|
|
412
|
+
trackingStart: "immediate",
|
|
413
|
+
userId: "visitor_external_merge",
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(mergeCartAttributes([
|
|
417
|
+
{ key: "gift_message", value: "Happy birthday", __typename: "Attribute" },
|
|
418
|
+
{ key: "apex_assignments", value: "prior assignments" },
|
|
419
|
+
{ key: "apex_visitor_id", value: "visitor_prior" },
|
|
420
|
+
{ key: "apex_session_id", value: "session_prior" },
|
|
421
|
+
{ key: "apex_assignment_authorization", value: "authorization_prior" },
|
|
422
|
+
{ key: "delivery_note", value: "Leave at door", extra: "drop me" },
|
|
423
|
+
])).toEqual([
|
|
424
|
+
{ key: "gift_message", value: "Happy birthday" },
|
|
425
|
+
{ key: "delivery_note", value: "Leave at door" },
|
|
426
|
+
{
|
|
427
|
+
key: "apex_assignments",
|
|
428
|
+
value: JSON.stringify([{
|
|
429
|
+
e: "exp_external_merge",
|
|
430
|
+
v: "var_external_merge",
|
|
431
|
+
ep: 1,
|
|
432
|
+
}]),
|
|
433
|
+
},
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
it("selects external all-scope declined cart labels by allowlisted key", async () => {
|
|
439
|
+
vi.doMock("./shopify-cart", async () => {
|
|
440
|
+
const actual = await vi.importActual<typeof import("./shopify-cart")>("./shopify-cart");
|
|
441
|
+
return {
|
|
442
|
+
...actual,
|
|
443
|
+
buildCartAttributeEntries: (...args: Parameters<typeof actual.buildCartAttributeEntries>) => (
|
|
444
|
+
actual.buildCartAttributeEntries(...args).reverse()
|
|
445
|
+
),
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
const { getCartAttributes, init } = await import("./index");
|
|
449
|
+
|
|
450
|
+
init({
|
|
451
|
+
antiFlickerMode: "off",
|
|
452
|
+
cartAttributeScope: "all",
|
|
453
|
+
cartAttributeWrite: "external",
|
|
454
|
+
consentMode: { enabled: true },
|
|
455
|
+
endpoint: "https://events.test",
|
|
456
|
+
experiments: [{
|
|
457
|
+
id: "exp_external_reordered",
|
|
458
|
+
status: "running",
|
|
459
|
+
trafficAllocation: 1,
|
|
460
|
+
variations: [{ id: "var_external_reordered", weight: 1, mutations: [] }],
|
|
461
|
+
}],
|
|
462
|
+
hasConsent: false,
|
|
463
|
+
shopId: "shop_123",
|
|
464
|
+
trackingStart: "immediate",
|
|
465
|
+
userId: "visitor_external_reordered",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const attributes = getCartAttributes();
|
|
469
|
+
expect(attributes).toHaveLength(1);
|
|
470
|
+
expect(attributes[0]).toEqual({
|
|
471
|
+
key: "apex_assignments",
|
|
472
|
+
value: JSON.stringify([{
|
|
473
|
+
e: "exp_external_reordered",
|
|
474
|
+
v: "var_external_reordered",
|
|
475
|
+
ep: 1,
|
|
476
|
+
}]),
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
it("returns no cart labels for a declined visitor with default scope", async () => {
|
|
482
|
+
const { getCartAttributes, init } = await import("./index");
|
|
483
|
+
|
|
484
|
+
init({
|
|
485
|
+
antiFlickerMode: "off",
|
|
486
|
+
consentMode: { enabled: true },
|
|
487
|
+
endpoint: "https://events.test",
|
|
488
|
+
experiments: [{
|
|
489
|
+
id: "exp_scope_default",
|
|
490
|
+
status: "running",
|
|
491
|
+
trafficAllocation: 1,
|
|
492
|
+
variations: [{ id: "var_scope_default", weight: 1, mutations: [] }],
|
|
493
|
+
}],
|
|
494
|
+
hasConsent: false,
|
|
495
|
+
shopId: "shop_123",
|
|
496
|
+
trackingStart: "immediate",
|
|
497
|
+
userId: "visitor_scope_default",
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
expect(getCartAttributes()).toEqual([]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
it("aborts an in-flight SDK cart write when config flips to external", async () => {
|
|
505
|
+
(globalThis as any).window.Shopify = {};
|
|
506
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
507
|
+
const experiment = {
|
|
508
|
+
id: "exp_external_flip",
|
|
509
|
+
status: "running" as const,
|
|
510
|
+
trafficAllocation: 1,
|
|
511
|
+
variations: [{ id: "var_external_flip", weight: 1, mutations: [] }],
|
|
512
|
+
};
|
|
513
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
514
|
+
fetchedAt: Date.now(),
|
|
515
|
+
hash: "cached-sdk-cart-write-owner",
|
|
516
|
+
experiments: [experiment],
|
|
517
|
+
runtime: { commerce: { cartAttributeWrite: "sdk" } },
|
|
518
|
+
}));
|
|
519
|
+
let cartWriteSignal: AbortSignal | undefined;
|
|
520
|
+
let resolveConfig!: (response: Response) => void;
|
|
521
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL, requestInit?: RequestInit) => {
|
|
522
|
+
if (String(input).includes("/config")) {
|
|
523
|
+
return new Promise<Response>((resolve) => { resolveConfig = resolve; });
|
|
524
|
+
}
|
|
525
|
+
if (input === "/cart/update.js") {
|
|
526
|
+
cartWriteSignal = requestInit?.signal ?? undefined;
|
|
527
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
528
|
+
cartWriteSignal?.addEventListener("abort", () => {
|
|
529
|
+
reject(new DOMException("The operation was aborted.", "AbortError"));
|
|
530
|
+
}, { once: true });
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
534
|
+
}) as any;
|
|
535
|
+
const { getDiagnostics, init } = await import("./index");
|
|
536
|
+
|
|
537
|
+
init({
|
|
538
|
+
antiFlickerMode: "off",
|
|
539
|
+
endpoint: "https://events.test",
|
|
540
|
+
lateConfigPolicy: "apply",
|
|
541
|
+
shopId: "shop_123",
|
|
542
|
+
trackingStart: "immediate",
|
|
543
|
+
userId: "visitor_external_flip",
|
|
544
|
+
});
|
|
545
|
+
await vi.waitFor(() => expect(cartWriteSignal).toBeDefined());
|
|
546
|
+
|
|
547
|
+
resolveConfig(Response.json({
|
|
548
|
+
experiments: [experiment],
|
|
549
|
+
runtime: { commerce: { cartAttributeWrite: "external" } },
|
|
550
|
+
}));
|
|
551
|
+
await vi.waitFor(() => expect(getDiagnostics().configSource).toBe("network"));
|
|
552
|
+
|
|
553
|
+
expect(cartWriteSignal?.aborted).toBe(true);
|
|
554
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(
|
|
555
|
+
([url]) => url === "/cart/update.js",
|
|
556
|
+
)).toHaveLength(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
it("does not let a legacy cached runtime override a fresh external write owner", async () => {
|
|
561
|
+
(globalThis as any).window.Shopify = {};
|
|
562
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
563
|
+
localStorage.setItem("drip_cfg:shop_123:production", JSON.stringify({
|
|
564
|
+
fetchedAt: Date.now(),
|
|
565
|
+
hash: "legacy-cache-before-cart-write-owner",
|
|
566
|
+
experiments: [{
|
|
567
|
+
id: "exp_legacy_external",
|
|
568
|
+
status: "running",
|
|
569
|
+
trafficAllocation: 1,
|
|
570
|
+
variations: [{ id: "var_legacy_external", weight: 1, mutations: [] }],
|
|
571
|
+
}],
|
|
572
|
+
runtime: { antiFlicker: { mode: "off" } },
|
|
573
|
+
}));
|
|
574
|
+
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
|
|
575
|
+
if (String(input).includes("/config")) return new Promise<Response>(() => {});
|
|
576
|
+
return Promise.resolve(new Response("ok", { status: 202 }));
|
|
577
|
+
}) as any;
|
|
578
|
+
const { init } = await import("./index");
|
|
579
|
+
|
|
580
|
+
init({
|
|
581
|
+
antiFlickerMode: "off",
|
|
582
|
+
cartAttributeWrite: "external",
|
|
583
|
+
endpoint: "https://events.test",
|
|
584
|
+
shopId: "shop_123",
|
|
585
|
+
trackingStart: "immediate",
|
|
586
|
+
userId: "visitor_legacy_external",
|
|
587
|
+
});
|
|
588
|
+
await flushDynamicImports();
|
|
589
|
+
|
|
590
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.some(
|
|
591
|
+
([url]) => url === "/cart/update.js",
|
|
592
|
+
)).toBe(false);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
it("clears prior signed Shopify treatment attributes for holdout when analytics is blocked", async () => {
|
|
597
|
+
(globalThis as any).window.Shopify = {};
|
|
598
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
599
|
+
const { getDiagnostics, init } = await import("./index");
|
|
600
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
601
|
+
const userId = Array.from({ length: 1000 }, (_, index) => `cart-holdout-${index}`).find((id) =>
|
|
602
|
+
isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id),
|
|
603
|
+
);
|
|
604
|
+
expect(userId).toBeTruthy();
|
|
605
|
+
|
|
606
|
+
init({
|
|
607
|
+
antiFlickerMode: "off",
|
|
608
|
+
commerce: {
|
|
609
|
+
offers: {
|
|
610
|
+
authorization: {
|
|
611
|
+
checksum: "a".repeat(64),
|
|
612
|
+
experimentIds: ["exp_holdout_cart"],
|
|
613
|
+
revision: 7,
|
|
614
|
+
version: 1,
|
|
615
|
+
},
|
|
616
|
+
experiments: [],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
consentMode: { enabled: true },
|
|
620
|
+
debug: { enabled: true, restrictMode: "always" },
|
|
621
|
+
endpoint: "https://events.test",
|
|
622
|
+
experiments: [
|
|
623
|
+
{
|
|
624
|
+
id: "exp_holdout_cart",
|
|
625
|
+
status: "running",
|
|
626
|
+
trafficAllocation: 1,
|
|
627
|
+
variations: [{ id: "var_holdout_cart", weight: 1, mutations: [] }],
|
|
628
|
+
},
|
|
629
|
+
],
|
|
630
|
+
holdout_v1_enabled: true,
|
|
631
|
+
holdout_pct: 10,
|
|
632
|
+
hasConsent: false,
|
|
633
|
+
shopId: "shop_123",
|
|
634
|
+
trackingStart: "immediate",
|
|
635
|
+
userId: userId!,
|
|
636
|
+
});
|
|
637
|
+
await flushDynamicImports();
|
|
638
|
+
|
|
639
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
640
|
+
const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
|
|
641
|
+
expect(cartUpdate).toBeTruthy();
|
|
642
|
+
expect(getDiagnostics().trackingBlocked).toBe(true);
|
|
643
|
+
expect(getDiagnostics().analyticsStarted).toBe(false);
|
|
644
|
+
const body = JSON.parse(String(cartUpdate?.[1]?.body));
|
|
645
|
+
const priorSignedAttributes = {
|
|
646
|
+
apex_assignments: JSON.stringify([{ e: "exp_prior", v: "var_prior", ep: 5 }]),
|
|
647
|
+
apex_assignment_authorization: "signed-prior-treatment",
|
|
648
|
+
apex_experiment_id: "exp_prior",
|
|
649
|
+
apex_variation_id: "var_prior",
|
|
650
|
+
apex_assignment_epoch: "5",
|
|
651
|
+
};
|
|
652
|
+
expect({ ...priorSignedAttributes, ...body.attributes }).toEqual({
|
|
653
|
+
apex_assignments: "[]",
|
|
654
|
+
apex_assignment_authorization: "",
|
|
655
|
+
apex_experiment_id: "",
|
|
656
|
+
apex_variation_id: "",
|
|
657
|
+
apex_assignment_epoch: "",
|
|
658
|
+
apex_visitor_id: userId,
|
|
659
|
+
apex_session_id: expect.any(String),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
it("keeps blocked active assignment cart sync analytics-gated", async () => {
|
|
665
|
+
(globalThis as any).window.Shopify = {};
|
|
666
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
667
|
+
const { init } = await import("./index");
|
|
668
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
669
|
+
const userId = Array.from({ length: 1000 }, (_, index) => `cart-blocked-active-${index}`)
|
|
670
|
+
.find((id) => !isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id));
|
|
671
|
+
expect(userId).toBeTruthy();
|
|
672
|
+
|
|
673
|
+
init({
|
|
674
|
+
antiFlickerMode: "off",
|
|
675
|
+
consentMode: { enabled: true },
|
|
676
|
+
endpoint: "https://events.test",
|
|
677
|
+
experiments: [
|
|
678
|
+
{
|
|
679
|
+
id: "exp_blocked_active_cart",
|
|
680
|
+
status: "running",
|
|
681
|
+
trafficAllocation: 1,
|
|
682
|
+
variations: [{ id: "var_blocked_active_cart", weight: 1, mutations: [] }],
|
|
683
|
+
},
|
|
684
|
+
],
|
|
685
|
+
hasConsent: false,
|
|
686
|
+
holdout_v1_enabled: true,
|
|
687
|
+
holdout_pct: 10,
|
|
688
|
+
shopId: "shop_123",
|
|
689
|
+
trackingStart: "immediate",
|
|
690
|
+
userId: userId!,
|
|
691
|
+
});
|
|
692
|
+
await flushDynamicImports();
|
|
693
|
+
|
|
694
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
695
|
+
expect(fetchMock.mock.calls.some(([url]) => url === "/cart/update.js")).toBe(false);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
it("mints arm labels only on the first product-page click when the served gate is true", async () => {
|
|
700
|
+
(globalThis as any).window.Shopify = {};
|
|
701
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
702
|
+
meta: { page: { pageType: "collection" } },
|
|
703
|
+
};
|
|
704
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
705
|
+
const { init } = await import("./index");
|
|
706
|
+
|
|
707
|
+
init({
|
|
708
|
+
cartAttributeScope: "all",
|
|
709
|
+
antiFlickerMode: "off",
|
|
710
|
+
consentMode: { enabled: true },
|
|
711
|
+
endpoint: "https://events.test",
|
|
712
|
+
experiments: [{
|
|
713
|
+
id: "exp_sdk_cart_mint",
|
|
714
|
+
status: "running",
|
|
715
|
+
trafficAllocation: 1,
|
|
716
|
+
variations: [{ id: "var_sdk_cart_mint", weight: 1, mutations: [] }],
|
|
717
|
+
}],
|
|
718
|
+
hasConsent: false,
|
|
719
|
+
shopId: "shop_123",
|
|
720
|
+
trackingStart: "immediate",
|
|
721
|
+
userId: "ephemeral_visitor",
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
725
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
726
|
+
|
|
727
|
+
(globalThis as any).window.ShopifyAnalytics.meta.page.pageType = "product";
|
|
728
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
729
|
+
await flushDynamicImports();
|
|
730
|
+
|
|
731
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
732
|
+
const cartCalls = fetchMock.mock.calls.filter(([url]) => url === "/cart/update.js");
|
|
733
|
+
expect(cartCalls).toHaveLength(1);
|
|
734
|
+
expect(JSON.parse(String(cartCalls[0]?.[1]?.body))).toEqual({
|
|
735
|
+
attributes: {
|
|
736
|
+
apex_assignments: JSON.stringify([{
|
|
737
|
+
e: "exp_sdk_cart_mint",
|
|
738
|
+
v: "var_sdk_cart_mint",
|
|
739
|
+
ep: 1,
|
|
740
|
+
}]),
|
|
741
|
+
apex_assignment_authorization: "",
|
|
742
|
+
apex_experiment_id: "",
|
|
743
|
+
apex_variation_id: "",
|
|
744
|
+
apex_assignment_epoch: "",
|
|
745
|
+
apex_visitor_id: "",
|
|
746
|
+
apex_session_id: "",
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
expect((globalThis as any).localStorage.length).toBe(0);
|
|
750
|
+
expect((globalThis as any).sessionStorage.length).toBe(0);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
it("uses the normal active cart sync when consent is already granted", async () => {
|
|
755
|
+
(globalThis as any).window.Shopify = {};
|
|
756
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
757
|
+
meta: { page: { pageType: "product" } },
|
|
758
|
+
};
|
|
759
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
760
|
+
const { init } = await import("./index");
|
|
761
|
+
|
|
762
|
+
init({
|
|
763
|
+
antiFlickerMode: "off",
|
|
764
|
+
cartAttributeScope: "all",
|
|
765
|
+
consentMode: { enabled: true },
|
|
766
|
+
endpoint: "https://events.test",
|
|
767
|
+
experiments: [{
|
|
768
|
+
id: "exp_granted_cart",
|
|
769
|
+
status: "running",
|
|
770
|
+
trafficAllocation: 1,
|
|
771
|
+
variations: [{ id: "var_granted_cart", weight: 1, mutations: [] }],
|
|
772
|
+
}],
|
|
773
|
+
hasConsent: true,
|
|
774
|
+
shopId: "shop_123",
|
|
775
|
+
trackingStart: "immediate",
|
|
776
|
+
userId: "visitor_granted_cart",
|
|
777
|
+
});
|
|
778
|
+
await flushDynamicImports();
|
|
779
|
+
|
|
780
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
781
|
+
await flushDynamicImports();
|
|
782
|
+
|
|
783
|
+
const cartCalls = vi.mocked(globalThis.fetch).mock.calls
|
|
784
|
+
.filter(([url]) => url === "/cart/update.js");
|
|
785
|
+
expect(cartCalls).toHaveLength(1);
|
|
786
|
+
expect(JSON.parse(String(cartCalls[0]?.[1]?.body)).attributes).toMatchObject({
|
|
787
|
+
apex_visitor_id: "visitor_granted_cart",
|
|
788
|
+
apex_session_id: expect.any(String),
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
it("does not mint cart labels when Do Not Track blocks tracking", async () => {
|
|
794
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
795
|
+
configurable: true,
|
|
796
|
+
value: { userAgent: "Mozilla/5.0", doNotTrack: "1" },
|
|
797
|
+
});
|
|
798
|
+
(globalThis as any).window.Shopify = {};
|
|
799
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
800
|
+
meta: { page: { pageType: "product" } },
|
|
801
|
+
};
|
|
802
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
803
|
+
const { init } = await import("./index");
|
|
804
|
+
|
|
805
|
+
init({
|
|
806
|
+
antiFlickerMode: "off",
|
|
807
|
+
cartAttributeScope: "all",
|
|
808
|
+
consentMode: { enabled: true },
|
|
809
|
+
dataProtection: { respectDoNotTrack: true, storageMode: "localStorage" },
|
|
810
|
+
endpoint: "https://events.test",
|
|
811
|
+
experiments: [{
|
|
812
|
+
id: "exp_dnt_cart",
|
|
813
|
+
status: "running",
|
|
814
|
+
trafficAllocation: 1,
|
|
815
|
+
variations: [{ id: "var_dnt_cart", weight: 1, mutations: [] }],
|
|
816
|
+
}],
|
|
817
|
+
hasConsent: false,
|
|
818
|
+
shopId: "shop_123",
|
|
819
|
+
trackingStart: "immediate",
|
|
820
|
+
userId: "visitor_dnt_cart",
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
824
|
+
await flushDynamicImports();
|
|
825
|
+
|
|
826
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) => url === "/cart/update.js"))
|
|
827
|
+
.toBe(false);
|
|
828
|
+
expect(documentListeners.click ?? []).toHaveLength(0);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
it("does not mint consent-pending cart attributes when an external integration owns writes", async () => {
|
|
833
|
+
(globalThis as any).window.Shopify = {};
|
|
834
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
835
|
+
meta: { page: { pageType: "product" } },
|
|
836
|
+
};
|
|
837
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
838
|
+
const { init } = await import("./index");
|
|
839
|
+
|
|
840
|
+
init({
|
|
841
|
+
cartAttributeScope: "all",
|
|
842
|
+
antiFlickerMode: "off",
|
|
843
|
+
cartAttributeWrite: "external",
|
|
844
|
+
consentMode: { enabled: true },
|
|
845
|
+
endpoint: "https://events.test",
|
|
846
|
+
experiments: [{
|
|
847
|
+
id: "exp_external_mint",
|
|
848
|
+
status: "running",
|
|
849
|
+
trafficAllocation: 1,
|
|
850
|
+
variations: [{ id: "var_external_mint", weight: 1, mutations: [] }],
|
|
851
|
+
}],
|
|
852
|
+
hasConsent: false,
|
|
853
|
+
shopId: "shop_123",
|
|
854
|
+
trackingStart: "immediate",
|
|
855
|
+
userId: "ephemeral_external_mint",
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
859
|
+
await flushDynamicImports();
|
|
860
|
+
|
|
861
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) => url === "/cart/update.js"))
|
|
862
|
+
.toBe(false);
|
|
863
|
+
expect(documentListeners.click ?? []).toHaveLength(0);
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
it("keeps the consent-pending mint default-off when the served gate is absent", async () => {
|
|
868
|
+
(globalThis as any).window.Shopify = {};
|
|
869
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
870
|
+
meta: { page: { pageType: "product" } },
|
|
871
|
+
};
|
|
872
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
873
|
+
const { init } = await import("./index");
|
|
874
|
+
|
|
875
|
+
init({
|
|
876
|
+
antiFlickerMode: "off",
|
|
877
|
+
consentMode: { enabled: true },
|
|
878
|
+
endpoint: "https://events.test",
|
|
879
|
+
experiments: [{
|
|
880
|
+
id: "exp_gate_off",
|
|
881
|
+
status: "running",
|
|
882
|
+
trafficAllocation: 1,
|
|
883
|
+
variations: [{ id: "var_gate_off", weight: 1, mutations: [] }],
|
|
884
|
+
}],
|
|
885
|
+
hasConsent: false,
|
|
886
|
+
shopId: "shop_123",
|
|
887
|
+
trackingStart: "immediate",
|
|
888
|
+
userId: "ephemeral_visitor",
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
892
|
+
await flushDynamicImports();
|
|
893
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
it("writes the full consented stamp after an in-flight minimal stamp", async () => {
|
|
898
|
+
(globalThis as any).window.Shopify = {};
|
|
899
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
900
|
+
meta: { page: { pageType: "product" } },
|
|
901
|
+
};
|
|
902
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
903
|
+
let finishMinimal!: (response: Response) => void;
|
|
904
|
+
globalThis.fetch = vi.fn((url: RequestInfo | URL) => {
|
|
905
|
+
const cartCallCount = vi.mocked(globalThis.fetch).mock.calls
|
|
906
|
+
.filter(([calledUrl]) => calledUrl === "/cart/update.js").length;
|
|
907
|
+
if (url === "/cart/update.js" && cartCallCount === 1) {
|
|
908
|
+
return new Promise<Response>((resolve) => { finishMinimal = resolve; });
|
|
909
|
+
}
|
|
910
|
+
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
911
|
+
}) as any;
|
|
912
|
+
const { init, setConsent } = await import("./index");
|
|
913
|
+
|
|
914
|
+
init({
|
|
915
|
+
cartAttributeScope: "all",
|
|
916
|
+
antiFlickerMode: "off",
|
|
917
|
+
consentMode: { enabled: true },
|
|
918
|
+
endpoint: "https://events.test",
|
|
919
|
+
experiments: [{
|
|
920
|
+
id: "exp_transition",
|
|
921
|
+
status: "running",
|
|
922
|
+
trafficAllocation: 1,
|
|
923
|
+
variations: [{ id: "var_transition", weight: 1, mutations: [] }],
|
|
924
|
+
}],
|
|
925
|
+
hasConsent: false,
|
|
926
|
+
shopId: "shop_123",
|
|
927
|
+
trackingStart: "immediate",
|
|
928
|
+
userId: "visitor_transition",
|
|
929
|
+
});
|
|
930
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
931
|
+
await Promise.resolve();
|
|
932
|
+
setConsent(true);
|
|
933
|
+
|
|
934
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
935
|
+
.toHaveLength(1);
|
|
936
|
+
finishMinimal(new Response("{}", { status: 200 }));
|
|
937
|
+
await flushDynamicImports();
|
|
938
|
+
|
|
939
|
+
const cartCalls = vi.mocked(globalThis.fetch).mock.calls
|
|
940
|
+
.filter(([url]) => url === "/cart/update.js");
|
|
941
|
+
expect(cartCalls).toHaveLength(2);
|
|
942
|
+
const minimal = JSON.parse(String(cartCalls[0]?.[1]?.body)).attributes;
|
|
943
|
+
const consented = JSON.parse(String(cartCalls[1]?.[1]?.body)).attributes;
|
|
944
|
+
expect(minimal).toMatchObject({
|
|
945
|
+
apex_assignment_authorization: "",
|
|
946
|
+
apex_experiment_id: "",
|
|
947
|
+
apex_variation_id: "",
|
|
948
|
+
apex_assignment_epoch: "",
|
|
949
|
+
apex_visitor_id: "",
|
|
950
|
+
apex_session_id: "",
|
|
951
|
+
});
|
|
952
|
+
expect(JSON.parse(consented.apex_assignments)).toEqual([
|
|
953
|
+
{ e: "exp_transition", v: "var_transition", ep: 1 },
|
|
954
|
+
]);
|
|
955
|
+
expect(consented).toMatchObject({
|
|
956
|
+
apex_visitor_id: "visitor_transition",
|
|
957
|
+
apex_session_id: expect.any(String),
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
it("finishes an in-flight consent-pending stamp before a consented re-init stamp", async () => {
|
|
963
|
+
(globalThis as any).window.Shopify = {};
|
|
964
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
965
|
+
meta: { page: { pageType: "product" } },
|
|
966
|
+
};
|
|
967
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
968
|
+
(globalThis as any).document.removeEventListener = vi.fn(
|
|
969
|
+
(type: string, handler: () => void) => {
|
|
970
|
+
documentListeners[type] = (documentListeners[type] ?? [])
|
|
971
|
+
.filter((candidate) => candidate !== handler);
|
|
972
|
+
},
|
|
973
|
+
);
|
|
974
|
+
let finishConfig!: (response: Response) => void;
|
|
975
|
+
let finishSdkCartMint!: () => void;
|
|
976
|
+
let cartAttributes: Record<string, string> = {};
|
|
977
|
+
const cachedExperiment = {
|
|
978
|
+
id: "exp_cached_sdk_cart_mint",
|
|
979
|
+
status: "running" as const,
|
|
980
|
+
trafficAllocation: 1,
|
|
981
|
+
variations: [{ id: "var_cached_sdk_cart_mint", weight: 1, mutations: [] }],
|
|
982
|
+
};
|
|
983
|
+
const networkExperiment = {
|
|
984
|
+
id: "exp_network_consented",
|
|
985
|
+
status: "running" as const,
|
|
986
|
+
trafficAllocation: 1,
|
|
987
|
+
variations: [{ id: "var_network_consented", weight: 1, mutations: [] }],
|
|
988
|
+
};
|
|
989
|
+
const runtime = {
|
|
990
|
+
commerce: { cartAttributeScope: "all" },
|
|
991
|
+
antiFlicker: { mode: "off" },
|
|
992
|
+
consentMode: { enabled: true },
|
|
993
|
+
};
|
|
994
|
+
const { __test, init, setConsent } = await import("./index");
|
|
995
|
+
(globalThis as any).localStorage.setItem(
|
|
996
|
+
__test!.getConfigCacheKey("shop_123"),
|
|
997
|
+
JSON.stringify({
|
|
998
|
+
experiments: [cachedExperiment],
|
|
999
|
+
fetchedAt: Date.now(),
|
|
1000
|
+
hash: "force-network-refresh",
|
|
1001
|
+
runtime,
|
|
1002
|
+
}),
|
|
1003
|
+
);
|
|
1004
|
+
globalThis.fetch = vi.fn((url: RequestInfo | URL, request?: RequestInit) => {
|
|
1005
|
+
if (String(url).includes("/config?")) {
|
|
1006
|
+
return new Promise<Response>((resolve) => { finishConfig = resolve; });
|
|
1007
|
+
}
|
|
1008
|
+
if (url === "/cart/update.js") {
|
|
1009
|
+
const nextAttributes = JSON.parse(String(request?.body)).attributes as Record<string, string>;
|
|
1010
|
+
const cartCallCount = vi.mocked(globalThis.fetch).mock.calls
|
|
1011
|
+
.filter(([calledUrl]) => calledUrl === "/cart/update.js").length;
|
|
1012
|
+
if (cartCallCount === 1) {
|
|
1013
|
+
return new Promise<Response>((resolve) => {
|
|
1014
|
+
finishSdkCartMint = () => {
|
|
1015
|
+
cartAttributes = { ...cartAttributes, ...nextAttributes };
|
|
1016
|
+
resolve(new Response("{}", { status: 200 }));
|
|
1017
|
+
};
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
cartAttributes = { ...cartAttributes, ...nextAttributes };
|
|
1021
|
+
return Promise.resolve(new Response("{}", { status: 200 }));
|
|
1022
|
+
}
|
|
1023
|
+
return Promise.resolve(new Response("{}", { status: 202 }));
|
|
1024
|
+
}) as any;
|
|
1025
|
+
|
|
1026
|
+
init({
|
|
1027
|
+
antiFlickerMode: "off",
|
|
1028
|
+
endpoint: "https://events.test",
|
|
1029
|
+
shopId: "shop_123",
|
|
1030
|
+
trackingStart: "immediate",
|
|
1031
|
+
userId: "visitor_reinit_ordering",
|
|
1032
|
+
});
|
|
1033
|
+
await vi.waitFor(() => expect(documentListeners.click).toHaveLength(1));
|
|
1034
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
1035
|
+
await vi.waitFor(() => {
|
|
1036
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
1037
|
+
.toHaveLength(1);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
1041
|
+
.toHaveLength(1);
|
|
1042
|
+
finishConfig(new Response(JSON.stringify({
|
|
1043
|
+
experiments: [networkExperiment],
|
|
1044
|
+
runtime,
|
|
1045
|
+
}), { status: 200 }));
|
|
1046
|
+
await flushDynamicImports();
|
|
1047
|
+
setConsent(true);
|
|
1048
|
+
await Promise.resolve();
|
|
1049
|
+
await Promise.resolve();
|
|
1050
|
+
|
|
1051
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
1052
|
+
.toHaveLength(1);
|
|
1053
|
+
finishSdkCartMint();
|
|
1054
|
+
await flushDynamicImports();
|
|
1055
|
+
|
|
1056
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
1057
|
+
.toHaveLength(2);
|
|
1058
|
+
expect(JSON.parse(cartAttributes.apex_assignments)).toEqual([{
|
|
1059
|
+
e: "exp_network_consented",
|
|
1060
|
+
v: "var_network_consented",
|
|
1061
|
+
ep: 1,
|
|
1062
|
+
}]);
|
|
1063
|
+
expect(cartAttributes).toMatchObject({
|
|
1064
|
+
apex_visitor_id: "visitor_reinit_ordering",
|
|
1065
|
+
apex_session_id: expect.any(String),
|
|
1066
|
+
});
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
it("writes the full consented stamp after the consent-pending mint rejects", async () => {
|
|
1071
|
+
(globalThis as any).window.Shopify = {};
|
|
1072
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
1073
|
+
meta: { page: { pageType: "product" } },
|
|
1074
|
+
};
|
|
1075
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1076
|
+
const shopifyCart = await import("./shopify-cart");
|
|
1077
|
+
const syncShopifyCartAttributes = vi.spyOn(shopifyCart, "syncShopifyCartAttributes");
|
|
1078
|
+
syncShopifyCartAttributes.mockRejectedValueOnce(new Error("mint rejected"));
|
|
1079
|
+
const { init, setConsent } = await import("./index");
|
|
1080
|
+
|
|
1081
|
+
init({
|
|
1082
|
+
cartAttributeScope: "all",
|
|
1083
|
+
antiFlickerMode: "off",
|
|
1084
|
+
consentMode: { enabled: true },
|
|
1085
|
+
endpoint: "https://events.test",
|
|
1086
|
+
experiments: [{
|
|
1087
|
+
id: "exp_rejected_transition",
|
|
1088
|
+
status: "running",
|
|
1089
|
+
trafficAllocation: 1,
|
|
1090
|
+
variations: [{ id: "var_rejected_transition", weight: 1, mutations: [] }],
|
|
1091
|
+
}],
|
|
1092
|
+
hasConsent: false,
|
|
1093
|
+
shopId: "shop_123",
|
|
1094
|
+
trackingStart: "immediate",
|
|
1095
|
+
userId: "visitor_rejected_transition",
|
|
1096
|
+
});
|
|
1097
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
1098
|
+
setConsent(true);
|
|
1099
|
+
await flushDynamicImports();
|
|
1100
|
+
|
|
1101
|
+
const consentedCalls = syncShopifyCartAttributes.mock.calls.slice(1)
|
|
1102
|
+
.filter(([input]) => input.visitorId === "visitor_rejected_transition");
|
|
1103
|
+
expect(consentedCalls.length).toBeGreaterThanOrEqual(1);
|
|
1104
|
+
expect(consentedCalls[0]?.[0]).toMatchObject({
|
|
1105
|
+
assignments: [{
|
|
1106
|
+
experimentId: "exp_rejected_transition",
|
|
1107
|
+
variationId: "var_rejected_transition",
|
|
1108
|
+
assignmentEpoch: 1,
|
|
1109
|
+
}],
|
|
1110
|
+
visitorId: "visitor_rejected_transition",
|
|
1111
|
+
});
|
|
1112
|
+
const cartCalls = vi.mocked(globalThis.fetch).mock.calls
|
|
1113
|
+
.filter(([url]) => url === "/cart/update.js");
|
|
1114
|
+
expect(cartCalls).toHaveLength(1);
|
|
1115
|
+
expect(JSON.parse(String(cartCalls[0]?.[1]?.body)).attributes).toMatchObject({
|
|
1116
|
+
apex_visitor_id: "visitor_rejected_transition",
|
|
1117
|
+
apex_session_id: expect.any(String),
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
it("keeps one consent-pending product-click handler across two inits", async () => {
|
|
1123
|
+
(globalThis as any).window.Shopify = {};
|
|
1124
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
1125
|
+
meta: { page: { pageType: "product" } },
|
|
1126
|
+
};
|
|
1127
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1128
|
+
(globalThis as any).document.removeEventListener = vi.fn(
|
|
1129
|
+
(type: string, handler: () => void) => {
|
|
1130
|
+
documentListeners[type] = (documentListeners[type] ?? [])
|
|
1131
|
+
.filter((candidate) => candidate !== handler);
|
|
1132
|
+
},
|
|
1133
|
+
);
|
|
1134
|
+
const experiment = {
|
|
1135
|
+
id: "exp_reinit_sdk_cart_mint",
|
|
1136
|
+
status: "running" as const,
|
|
1137
|
+
trafficAllocation: 1,
|
|
1138
|
+
variations: [{ id: "var_reinit_sdk_cart_mint", weight: 1, mutations: [] }],
|
|
1139
|
+
};
|
|
1140
|
+
const runtime = {
|
|
1141
|
+
commerce: { cartAttributeScope: "all" },
|
|
1142
|
+
antiFlicker: { mode: "off" },
|
|
1143
|
+
consentMode: { enabled: true },
|
|
1144
|
+
};
|
|
1145
|
+
const { __test, init } = await import("./index");
|
|
1146
|
+
(globalThis as any).localStorage.setItem(
|
|
1147
|
+
__test!.getConfigCacheKey("shop_123"),
|
|
1148
|
+
JSON.stringify({
|
|
1149
|
+
experiments: [experiment],
|
|
1150
|
+
fetchedAt: Date.now(),
|
|
1151
|
+
hash: "force-network-refresh",
|
|
1152
|
+
runtime,
|
|
1153
|
+
}),
|
|
1154
|
+
);
|
|
1155
|
+
globalThis.fetch = vi.fn(async (url: RequestInfo | URL) => {
|
|
1156
|
+
if (String(url).includes("/config?")) {
|
|
1157
|
+
return new Response(JSON.stringify({ experiments: [experiment], runtime }), {
|
|
1158
|
+
status: 200,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
return new Response("{}", { status: 200 });
|
|
1162
|
+
}) as any;
|
|
1163
|
+
|
|
1164
|
+
init({
|
|
1165
|
+
antiFlickerMode: "off",
|
|
1166
|
+
endpoint: "https://events.test",
|
|
1167
|
+
shopId: "shop_123",
|
|
1168
|
+
trackingStart: "immediate",
|
|
1169
|
+
userId: "visitor_reinit_sdk_cart_mint",
|
|
1170
|
+
});
|
|
1171
|
+
await flushDynamicImports();
|
|
1172
|
+
|
|
1173
|
+
expect(documentListeners.click).toHaveLength(1);
|
|
1174
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
1175
|
+
await flushDynamicImports();
|
|
1176
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.filter(([url]) => url === "/cart/update.js"))
|
|
1177
|
+
.toHaveLength(1);
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
it("does not build or write consent-pending cart attributes without assignments", async () => {
|
|
1182
|
+
(globalThis as any).window.Shopify = {};
|
|
1183
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
1184
|
+
meta: { page: { pageType: "product" } },
|
|
1185
|
+
};
|
|
1186
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1187
|
+
const { getCartAttributes, init } = await import("./index");
|
|
1188
|
+
|
|
1189
|
+
init({
|
|
1190
|
+
cartAttributeScope: "all",
|
|
1191
|
+
antiFlickerMode: "off",
|
|
1192
|
+
consentMode: { enabled: true },
|
|
1193
|
+
endpoint: "https://events.test",
|
|
1194
|
+
experiments: [],
|
|
1195
|
+
hasConsent: false,
|
|
1196
|
+
shopId: "shop_123",
|
|
1197
|
+
trackingStart: "immediate",
|
|
1198
|
+
userId: "visitor_without_assignment",
|
|
1199
|
+
});
|
|
1200
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
1201
|
+
await flushDynamicImports();
|
|
1202
|
+
|
|
1203
|
+
expect(getCartAttributes()).toEqual([]);
|
|
1204
|
+
expect(vi.mocked(globalThis.fetch).mock.calls.some(([url]) => url === "/cart/update.js"))
|
|
1205
|
+
.toBe(false);
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
it("installs the consent-pending mint trigger when a manual callback activates later", async () => {
|
|
1210
|
+
(globalThis as any).window.Shopify = {};
|
|
1211
|
+
(globalThis as any).window.ShopifyAnalytics = {
|
|
1212
|
+
meta: { page: { pageType: "product" } },
|
|
1213
|
+
};
|
|
1214
|
+
(globalThis as any).window.location = (globalThis as any).location;
|
|
1215
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1216
|
+
const { getAssignments, init } = await import("./index");
|
|
1217
|
+
|
|
1218
|
+
init({
|
|
1219
|
+
cartAttributeScope: "all",
|
|
1220
|
+
antiFlickerMode: "off",
|
|
1221
|
+
consentMode: { enabled: true },
|
|
1222
|
+
endpoint: "https://events.test",
|
|
1223
|
+
experiments: [{
|
|
1224
|
+
id: "exp_late_sdk_cart_mint",
|
|
1225
|
+
status: "running",
|
|
1226
|
+
targeting: {
|
|
1227
|
+
pageRules: [{
|
|
1228
|
+
id: "page_late_sdk_cart_mint",
|
|
1229
|
+
includeRules: [{ type: "simple", pattern: "/", include: true }],
|
|
1230
|
+
trigger: {
|
|
1231
|
+
mode: "manual-callback",
|
|
1232
|
+
apiName: "late_sdk_cart_mint",
|
|
1233
|
+
signal: "page.trigger.late_sdk_cart_mint",
|
|
1234
|
+
triggerJs: "window.__activateLateSdkCartMint = activate;",
|
|
1235
|
+
},
|
|
1236
|
+
}],
|
|
1237
|
+
},
|
|
1238
|
+
trafficAllocation: 1,
|
|
1239
|
+
variations: [{ id: "var_late_sdk_cart_mint", weight: 1, mutations: [] }],
|
|
1240
|
+
}],
|
|
1241
|
+
hasConsent: false,
|
|
1242
|
+
shopId: "shop_123",
|
|
1243
|
+
trackingStart: "immediate",
|
|
1244
|
+
userId: "visitor_late_sdk_cart_mint",
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
expect(getAssignments()).toHaveLength(0);
|
|
1248
|
+
(globalThis as any).window.__activateLateSdkCartMint();
|
|
1249
|
+
expect(getAssignments()).toEqual([
|
|
1250
|
+
expect.objectContaining({
|
|
1251
|
+
experimentId: "exp_late_sdk_cart_mint",
|
|
1252
|
+
variationId: "var_late_sdk_cart_mint",
|
|
1253
|
+
}),
|
|
1254
|
+
]);
|
|
1255
|
+
for (const handler of documentListeners.click ?? []) handler();
|
|
1256
|
+
await flushDynamicImports();
|
|
1257
|
+
|
|
1258
|
+
const cartCall = vi.mocked(globalThis.fetch).mock.calls.find(
|
|
1259
|
+
([url]) => url === "/cart/update.js",
|
|
1260
|
+
);
|
|
1261
|
+
expect(cartCall).toBeDefined();
|
|
1262
|
+
expect(JSON.parse(String(cartCall?.[1]?.body)).attributes.apex_assignments).toBe(
|
|
1263
|
+
JSON.stringify([{ e: "exp_late_sdk_cart_mint", v: "var_late_sdk_cart_mint", ep: 1 }]),
|
|
1264
|
+
);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
it("writes only visitor identity for an active holdout-program visitor with zero assignments", async () => {
|
|
1269
|
+
(globalThis as any).window.Shopify = {};
|
|
1270
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1271
|
+
const { getCartAttributes, init, mergeCartAttributes } = await import("./index");
|
|
1272
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
1273
|
+
const userId = Array.from({ length: 1000 }, (_, index) => `cart-zero-active-${index}`)
|
|
1274
|
+
.find((id) => !isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id));
|
|
1275
|
+
expect(userId).toBeTruthy();
|
|
1276
|
+
|
|
1277
|
+
init({
|
|
1278
|
+
antiFlickerMode: "off",
|
|
1279
|
+
endpoint: "https://events.test",
|
|
1280
|
+
experiments: [
|
|
1281
|
+
{
|
|
1282
|
+
id: "exp_zero_active_cart",
|
|
1283
|
+
status: "running",
|
|
1284
|
+
trafficAllocation: 0,
|
|
1285
|
+
variations: [{ id: "var_zero_active_cart", weight: 1, mutations: [] }],
|
|
1286
|
+
},
|
|
1287
|
+
],
|
|
1288
|
+
holdout_v1_enabled: true,
|
|
1289
|
+
holdout_pct: 10,
|
|
1290
|
+
shopId: "shop_123",
|
|
1291
|
+
trackingStart: "immediate",
|
|
1292
|
+
userId: userId!,
|
|
1293
|
+
});
|
|
1294
|
+
await flushDynamicImports();
|
|
1295
|
+
|
|
1296
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
1297
|
+
const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
|
|
1298
|
+
expect(cartUpdate).toBeTruthy();
|
|
1299
|
+
const body = JSON.parse(String(cartUpdate?.[1]?.body));
|
|
1300
|
+
expect(body.attributes).toEqual({ apex_visitor_id: userId });
|
|
1301
|
+
expect(getCartAttributes()).toEqual([{ key: "apex_visitor_id", value: userId }]);
|
|
1302
|
+
expect(mergeCartAttributes([
|
|
1303
|
+
{ key: "gift_message", value: "Happy birthday", __typename: "Attribute" },
|
|
1304
|
+
{ key: "apex_assignments", value: "preserved assignments", __typename: "Attribute" },
|
|
1305
|
+
{ key: "apex_variation_id", value: "preserved variation" },
|
|
1306
|
+
{ key: "apex_visitor_id", value: "visitor_stale", __typename: "Attribute" },
|
|
1307
|
+
])).toEqual([
|
|
1308
|
+
{ key: "gift_message", value: "Happy birthday" },
|
|
1309
|
+
{ key: "apex_assignments", value: "preserved assignments" },
|
|
1310
|
+
{ key: "apex_variation_id", value: "preserved variation" },
|
|
1311
|
+
{ key: "apex_visitor_id", value: userId },
|
|
1312
|
+
]);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
it("keeps the ordinary Shopify assignment stamp for an active holdout-program visitor", async () => {
|
|
1317
|
+
(globalThis as any).window.Shopify = {};
|
|
1318
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1319
|
+
const { init } = await import("./index");
|
|
1320
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
1321
|
+
const userId = Array.from({ length: 1000 }, (_, index) => `cart-active-${index}`).find((id) =>
|
|
1322
|
+
!isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id),
|
|
1323
|
+
);
|
|
1324
|
+
expect(userId).toBeTruthy();
|
|
1325
|
+
|
|
1326
|
+
init({
|
|
1327
|
+
antiFlickerMode: "off",
|
|
1328
|
+
endpoint: "https://events.test",
|
|
1329
|
+
experiments: [
|
|
1330
|
+
{
|
|
1331
|
+
id: "exp_active_cart",
|
|
1332
|
+
status: "running",
|
|
1333
|
+
trafficAllocation: 1,
|
|
1334
|
+
variations: [{ id: "var_active_cart", weight: 1, mutations: [] }],
|
|
1335
|
+
},
|
|
1336
|
+
],
|
|
1337
|
+
holdout_v1_enabled: true,
|
|
1338
|
+
holdout_pct: 10,
|
|
1339
|
+
shopId: "shop_123",
|
|
1340
|
+
trackingStart: "immediate",
|
|
1341
|
+
userId: userId!,
|
|
1342
|
+
});
|
|
1343
|
+
await flushDynamicImports();
|
|
1344
|
+
|
|
1345
|
+
const fetchMock = vi.mocked(globalThis.fetch);
|
|
1346
|
+
const cartUpdate = fetchMock.mock.calls.find(([url]) => url === "/cart/update.js");
|
|
1347
|
+
expect(cartUpdate).toBeTruthy();
|
|
1348
|
+
const body = JSON.parse(String(cartUpdate?.[1]?.body));
|
|
1349
|
+
expect(body.attributes).toEqual({
|
|
1350
|
+
apex_assignments: JSON.stringify([{ e: "exp_active_cart", v: "var_active_cart", ep: 1 }]),
|
|
1351
|
+
apex_assignment_authorization: "",
|
|
1352
|
+
apex_experiment_id: "exp_active_cart",
|
|
1353
|
+
apex_variation_id: "var_active_cart",
|
|
1354
|
+
apex_assignment_epoch: "1",
|
|
1355
|
+
apex_visitor_id: userId,
|
|
1356
|
+
apex_session_id: expect.any(String),
|
|
1357
|
+
});
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
it("exposes ordered cart attributes for headless Storefront API carts", async () => {
|
|
1362
|
+
const { getCartAttributes, init } = await import("./index");
|
|
1363
|
+
|
|
1364
|
+
init({
|
|
1365
|
+
antiFlickerMode: "off",
|
|
1366
|
+
endpoint: "https://events.test",
|
|
1367
|
+
experiments: [
|
|
1368
|
+
{
|
|
1369
|
+
id: "exp_headless_first",
|
|
1370
|
+
status: "running",
|
|
1371
|
+
trafficAllocation: 1,
|
|
1372
|
+
variations: [{ id: "var_headless_first", weight: 1, mutations: [] }],
|
|
1373
|
+
},
|
|
1374
|
+
{
|
|
1375
|
+
id: "exp_headless_second",
|
|
1376
|
+
status: "running",
|
|
1377
|
+
trafficAllocation: 1,
|
|
1378
|
+
variations: [{ id: "var_headless_second", weight: 1, mutations: [] }],
|
|
1379
|
+
},
|
|
1380
|
+
],
|
|
1381
|
+
shopId: "shop_123",
|
|
1382
|
+
trackingStart: "immediate",
|
|
1383
|
+
userId: "visitor_123",
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
const entries = getCartAttributes();
|
|
1387
|
+
expect(entries.map((entry) => entry.key)).toEqual([
|
|
1388
|
+
"apex_assignments",
|
|
1389
|
+
"apex_assignment_authorization",
|
|
1390
|
+
"apex_experiment_id",
|
|
1391
|
+
"apex_variation_id",
|
|
1392
|
+
"apex_assignment_epoch",
|
|
1393
|
+
"apex_visitor_id",
|
|
1394
|
+
"apex_session_id",
|
|
1395
|
+
]);
|
|
1396
|
+
expect(JSON.parse(entries[0]!.value)).toEqual([
|
|
1397
|
+
{ e: "exp_headless_first", v: "var_headless_first", ep: 1 },
|
|
1398
|
+
{ e: "exp_headless_second", v: "var_headless_second", ep: 1 },
|
|
1399
|
+
]);
|
|
1400
|
+
expect(entries).toEqual(expect.arrayContaining([
|
|
1401
|
+
{ key: "apex_experiment_id", value: "exp_headless_first" },
|
|
1402
|
+
{ key: "apex_variation_id", value: "var_headless_first" },
|
|
1403
|
+
{ key: "apex_visitor_id", value: "visitor_123" },
|
|
1404
|
+
{ key: "apex_session_id", value: expect.any(String) },
|
|
1405
|
+
]));
|
|
1406
|
+
expect((globalThis as any).drip.getCartAttributes()).toEqual(entries);
|
|
1407
|
+
expect((globalThis as any).Apex.getCartAttributes()).toEqual(entries);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
it("exposes non-empty cart attributes as composite-key metafields through module and browser APIs", async () => {
|
|
1412
|
+
const {
|
|
1413
|
+
getCartAttributes,
|
|
1414
|
+
getCartMetafields,
|
|
1415
|
+
getCartMetafieldStaleKeys,
|
|
1416
|
+
init,
|
|
1417
|
+
} = await import("./index");
|
|
1418
|
+
init({
|
|
1419
|
+
antiFlickerMode: "off",
|
|
1420
|
+
endpoint: "https://events.test",
|
|
1421
|
+
experiments: [{
|
|
1422
|
+
id: "exp_headless_metafields",
|
|
1423
|
+
status: "running",
|
|
1424
|
+
trafficAllocation: 1,
|
|
1425
|
+
variations: [{ id: "var_headless_metafields", weight: 1, mutations: [] }],
|
|
1426
|
+
}],
|
|
1427
|
+
shopId: "shop_123",
|
|
1428
|
+
trackingStart: "immediate",
|
|
1429
|
+
userId: "visitor_123",
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
const metafields = getCartMetafields({ namespace: "merchant_bi" });
|
|
1433
|
+
expect((globalThis as any).drip.getCartMetafields({ namespace: "merchant_bi" }))
|
|
1434
|
+
.toEqual(metafields);
|
|
1435
|
+
expect((globalThis as any).Apex.getCartMetafields({ namespace: "merchant_bi" }))
|
|
1436
|
+
.toEqual(metafields);
|
|
1437
|
+
const staleKeys = getCartMetafieldStaleKeys({ namespace: "merchant_bi" });
|
|
1438
|
+
expect((globalThis as any).drip.getCartMetafieldStaleKeys({ namespace: "merchant_bi" }))
|
|
1439
|
+
.toEqual(staleKeys);
|
|
1440
|
+
expect((globalThis as any).Apex.getCartMetafieldStaleKeys({ namespace: "merchant_bi" }))
|
|
1441
|
+
.toEqual(staleKeys);
|
|
1442
|
+
expect(staleKeys).toEqual([
|
|
1443
|
+
"merchant_bi.apex_assignment_authorization",
|
|
1444
|
+
]);
|
|
1445
|
+
expect(metafields.map(({ key, value }) => ({
|
|
1446
|
+
key: key.replace(/^merchant_bi\./, ""),
|
|
1447
|
+
value,
|
|
1448
|
+
}))).toEqual(getCartAttributes().filter(({ value }) => value !== ""));
|
|
1449
|
+
expect(metafields.every((entry) => entry.key.startsWith("merchant_bi."))).toBe(true);
|
|
1450
|
+
expect(metafields.every((entry) => !("namespace" in entry))).toBe(true);
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
it("falls back to the apex namespace when the requested namespace is invalid", async () => {
|
|
1455
|
+
const { getCartMetafields, init } = await import("./index");
|
|
1456
|
+
init({
|
|
1457
|
+
antiFlickerMode: "off",
|
|
1458
|
+
endpoint: "https://events.test",
|
|
1459
|
+
experiments: [{
|
|
1460
|
+
id: "exp_headless_metafields",
|
|
1461
|
+
status: "running",
|
|
1462
|
+
trafficAllocation: 1,
|
|
1463
|
+
variations: [{ id: "var_headless_metafields", weight: 1, mutations: [] }],
|
|
1464
|
+
}],
|
|
1465
|
+
shopId: "shop_123",
|
|
1466
|
+
trackingStart: "immediate",
|
|
1467
|
+
userId: "visitor_123",
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
for (const namespace of ["a", "merchant.bi", "merchant bi", "x".repeat(256)]) {
|
|
1471
|
+
expect(getCartMetafields({ namespace }).every(
|
|
1472
|
+
({ key }) => key.startsWith("apex."),
|
|
1473
|
+
)).toBe(true);
|
|
1474
|
+
}
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
it("keeps cart metafields empty when initialized without an active assignment", async () => {
|
|
1479
|
+
const { getCartMetafields, getCartMetafieldStaleKeys, init } = await import("./index");
|
|
1480
|
+
init({
|
|
1481
|
+
antiFlickerMode: "off",
|
|
1482
|
+
endpoint: "https://events.test",
|
|
1483
|
+
experiments: [],
|
|
1484
|
+
shopId: "shop_123",
|
|
1485
|
+
trackingStart: "immediate",
|
|
1486
|
+
userId: "visitor_123",
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
expect(getCartMetafields()).toEqual([]);
|
|
1490
|
+
expect(getCartMetafields()).toEqual([]);
|
|
1491
|
+
expect(getCartMetafieldStaleKeys()).toEqual([
|
|
1492
|
+
"apex.apex_assignments",
|
|
1493
|
+
"apex.apex_assignment_authorization",
|
|
1494
|
+
"apex.apex_experiment_id",
|
|
1495
|
+
"apex.apex_variation_id",
|
|
1496
|
+
"apex.apex_assignment_epoch",
|
|
1497
|
+
"apex.apex_visitor_id",
|
|
1498
|
+
"apex.apex_session_id",
|
|
1499
|
+
]);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
it("returns stable empty cart metafields when uninitialized or consent-blocked", async () => {
|
|
1504
|
+
const { getCartMetafields, init } = await import("./index");
|
|
1505
|
+
expect(getCartMetafields()).toEqual([]);
|
|
1506
|
+
expect(getCartMetafields()).toEqual([]);
|
|
1507
|
+
|
|
1508
|
+
init({
|
|
1509
|
+
antiFlickerMode: "off",
|
|
1510
|
+
consentMode: { enabled: true },
|
|
1511
|
+
endpoint: "https://events.test",
|
|
1512
|
+
experiments: [],
|
|
1513
|
+
hasConsent: false,
|
|
1514
|
+
shopId: "shop_123",
|
|
1515
|
+
trackingStart: "immediate",
|
|
1516
|
+
userId: "visitor_123",
|
|
1517
|
+
});
|
|
1518
|
+
expect(getCartMetafields()).toEqual([]);
|
|
1519
|
+
expect((globalThis as any).drip.getCartMetafields()).toEqual([]);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
it("preserves and normalizes existing cart attributes when tracking consent blocks a fresh stamp", async () => {
|
|
1524
|
+
const { getCartAttributes, init, mergeCartAttributes } = await import("./index");
|
|
1525
|
+
|
|
1526
|
+
init({
|
|
1527
|
+
antiFlickerMode: "off",
|
|
1528
|
+
consentMode: { enabled: true },
|
|
1529
|
+
endpoint: "https://events.test",
|
|
1530
|
+
experiments: [
|
|
1531
|
+
{
|
|
1532
|
+
id: "exp_consent_blocked_cart",
|
|
1533
|
+
status: "running",
|
|
1534
|
+
trafficAllocation: 1,
|
|
1535
|
+
variations: [{ id: "var_consent_blocked_cart", weight: 1, mutations: [] }],
|
|
1536
|
+
},
|
|
1537
|
+
],
|
|
1538
|
+
hasConsent: false,
|
|
1539
|
+
shopId: "shop_123",
|
|
1540
|
+
trackingStart: "immediate",
|
|
1541
|
+
userId: "visitor_123",
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
expect(getCartAttributes()).toEqual([]);
|
|
1545
|
+
expect((globalThis as any).drip.getCartAttributes()).toEqual([]);
|
|
1546
|
+
expect(mergeCartAttributes([
|
|
1547
|
+
{ key: "gift_message", value: "Happy birthday", __typename: "Attribute" },
|
|
1548
|
+
{ key: "apex_assignments", value: "prior assignments", __typename: "Attribute" },
|
|
1549
|
+
{ key: "apex_visitor_id", value: "visitor_prior", extra: "drop me" },
|
|
1550
|
+
{ key: "missing_value" },
|
|
1551
|
+
{ key: "numeric_value", value: 42 },
|
|
1552
|
+
{ key: 42, value: "numeric key" },
|
|
1553
|
+
null,
|
|
1554
|
+
])).toEqual([
|
|
1555
|
+
{ key: "gift_message", value: "Happy birthday" },
|
|
1556
|
+
{ key: "apex_assignments", value: "prior assignments" },
|
|
1557
|
+
{ key: "apex_visitor_id", value: "visitor_prior" },
|
|
1558
|
+
]);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
it("merges fresh Apex attribution without replacing foreign cart attributes", async () => {
|
|
1563
|
+
const { getCartAttributes, init, mergeCartAttributes } = await import("./index");
|
|
1564
|
+
|
|
1565
|
+
init({
|
|
1566
|
+
antiFlickerMode: "off",
|
|
1567
|
+
endpoint: "https://events.test",
|
|
1568
|
+
experiments: [
|
|
1569
|
+
{
|
|
1570
|
+
id: "exp_headless_merge",
|
|
1571
|
+
status: "running",
|
|
1572
|
+
trafficAllocation: 1,
|
|
1573
|
+
variations: [{ id: "var_headless_merge", weight: 1, mutations: [] }],
|
|
1574
|
+
},
|
|
1575
|
+
],
|
|
1576
|
+
shopId: "shop_123",
|
|
1577
|
+
trackingStart: "immediate",
|
|
1578
|
+
userId: "visitor_fresh",
|
|
1579
|
+
});
|
|
1580
|
+
|
|
1581
|
+
const existing = [
|
|
1582
|
+
{ key: "gift_message", value: "Happy birthday", __typename: "Attribute" },
|
|
1583
|
+
{ key: "apex_visitor_id", value: "visitor_stale", __typename: "Attribute" },
|
|
1584
|
+
{ key: "delivery_note", value: "Leave at door", extra: "drop me" },
|
|
1585
|
+
{ key: "apex_assignments", value: "stale" },
|
|
1586
|
+
{ key: "missing_value" },
|
|
1587
|
+
{ key: "numeric_value", value: 42 },
|
|
1588
|
+
{ key: 42, value: "numeric key" },
|
|
1589
|
+
null,
|
|
1590
|
+
];
|
|
1591
|
+
const fresh = getCartAttributes();
|
|
1592
|
+
const merged = mergeCartAttributes(existing);
|
|
1593
|
+
|
|
1594
|
+
expect(merged).not.toBe(existing);
|
|
1595
|
+
expect(existing).toHaveLength(8);
|
|
1596
|
+
expect(merged).toEqual([
|
|
1597
|
+
{ key: "gift_message", value: "Happy birthday" },
|
|
1598
|
+
{ key: "delivery_note", value: "Leave at door" },
|
|
1599
|
+
...fresh,
|
|
1600
|
+
]);
|
|
1601
|
+
for (const entry of fresh) {
|
|
1602
|
+
expect(merged.filter(({ key }) => key === entry.key)).toEqual([entry]);
|
|
1603
|
+
}
|
|
1604
|
+
expect((globalThis as any).drip.mergeCartAttributes(existing)).toEqual(merged);
|
|
1605
|
+
expect((globalThis as any).Apex.mergeCartAttributes(existing)).toEqual(merged);
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
it("replaces a headless minimal stamp with one consented assignment stamp", async () => {
|
|
1610
|
+
const { getCartAttributes, init, mergeCartAttributes, setConsent } = await import("./index");
|
|
1611
|
+
|
|
1612
|
+
init({
|
|
1613
|
+
cartAttributeScope: "all",
|
|
1614
|
+
antiFlickerMode: "off",
|
|
1615
|
+
consentMode: { enabled: true },
|
|
1616
|
+
endpoint: "https://events.test",
|
|
1617
|
+
experiments: [{
|
|
1618
|
+
id: "exp_headless_transition",
|
|
1619
|
+
status: "running",
|
|
1620
|
+
trafficAllocation: 1,
|
|
1621
|
+
variations: [{ id: "var_headless_transition", weight: 1, mutations: [] }],
|
|
1622
|
+
}],
|
|
1623
|
+
hasConsent: false,
|
|
1624
|
+
shopId: "shop_123",
|
|
1625
|
+
trackingStart: "immediate",
|
|
1626
|
+
userId: "visitor_headless_transition",
|
|
1627
|
+
});
|
|
1628
|
+
const minimal = getCartAttributes();
|
|
1629
|
+
expect(minimal).toHaveLength(7);
|
|
1630
|
+
|
|
1631
|
+
setConsent(true);
|
|
1632
|
+
const merged = mergeCartAttributes([
|
|
1633
|
+
{ key: "delivery_note", value: "Leave at door" },
|
|
1634
|
+
...minimal,
|
|
1635
|
+
]);
|
|
1636
|
+
|
|
1637
|
+
expect(merged.filter(({ key }) => key === "apex_assignments")).toHaveLength(1);
|
|
1638
|
+
expect(merged).toEqual([
|
|
1639
|
+
{ key: "delivery_note", value: "Leave at door" },
|
|
1640
|
+
...getCartAttributes(),
|
|
1641
|
+
]);
|
|
1642
|
+
expect(merged).toContainEqual({
|
|
1643
|
+
key: "apex_visitor_id",
|
|
1644
|
+
value: "visitor_headless_transition",
|
|
1645
|
+
});
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
it("safely treats non-arrays as empty and drops malformed entries", async () => {
|
|
1650
|
+
const { getCartAttributes, mergeCartAttributes } = await import("./index");
|
|
1651
|
+
const fresh = getCartAttributes();
|
|
1652
|
+
|
|
1653
|
+
expect(mergeCartAttributes(null)).toEqual(fresh);
|
|
1654
|
+
expect(mergeCartAttributes(undefined)).toEqual(fresh);
|
|
1655
|
+
expect(mergeCartAttributes("garbage")).toEqual(fresh);
|
|
1656
|
+
expect(mergeCartAttributes([
|
|
1657
|
+
null,
|
|
1658
|
+
{},
|
|
1659
|
+
{ key: 42, value: "bad" },
|
|
1660
|
+
{ key: "missing" },
|
|
1661
|
+
{ key: "bad", value: 42 },
|
|
1662
|
+
])).toEqual(fresh);
|
|
1663
|
+
});
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
it("returns no cart attributes without active assignments", async () => {
|
|
1667
|
+
const { getCartAttributes, init } = await import("./index");
|
|
1668
|
+
|
|
1669
|
+
init({
|
|
1670
|
+
antiFlickerMode: "off",
|
|
1671
|
+
endpoint: "https://events.test",
|
|
1672
|
+
experiments: [],
|
|
1673
|
+
shopId: "shop_123",
|
|
1674
|
+
trackingStart: "immediate",
|
|
1675
|
+
userId: "visitor_123",
|
|
1676
|
+
});
|
|
1677
|
+
|
|
1678
|
+
expect(getCartAttributes()).toEqual([]);
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
it("defers Shopify assignment sync until a held SPA route recheck completes", async () => {
|
|
1683
|
+
const shopifyPublish = vi.fn();
|
|
1684
|
+
(globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
|
|
1685
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1686
|
+
const { getAssignments, hold, init, release } = await import("./index");
|
|
1687
|
+
|
|
1688
|
+
init({
|
|
1689
|
+
antiFlickerMode: "off",
|
|
1690
|
+
endpoint: "https://events.test",
|
|
1691
|
+
experiments: [
|
|
1692
|
+
{
|
|
1693
|
+
id: "exp_spa_initial_sync",
|
|
1694
|
+
status: "running",
|
|
1695
|
+
trafficAllocation: 1,
|
|
1696
|
+
variations: [{ id: "var_spa_initial_sync", weight: 1, mutations: [] }],
|
|
1697
|
+
},
|
|
1698
|
+
{
|
|
1699
|
+
id: "exp_spa_held_sync",
|
|
1700
|
+
status: "running",
|
|
1701
|
+
targeting: { url: [{ type: "simple", pattern: "/next" }] },
|
|
1702
|
+
trafficAllocation: 1,
|
|
1703
|
+
variations: [{ id: "var_spa_held_sync", weight: 1, mutations: [] }],
|
|
1704
|
+
},
|
|
1705
|
+
],
|
|
1706
|
+
shopId: "shop_123",
|
|
1707
|
+
spa: { hydrationTimeoutMs: 3_000, routerMode: "history" },
|
|
1708
|
+
trackingStart: "immediate",
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
expect(getAssignments().some((assignment) => assignment.experimentId === "exp_spa_held_sync")).toBe(false);
|
|
1712
|
+
shopifyPublish.mockClear();
|
|
1713
|
+
hold();
|
|
1714
|
+
(globalThis as any).location.href = "https://plain.test/middle";
|
|
1715
|
+
(globalThis as any).location.pathname = "/middle";
|
|
1716
|
+
(globalThis as any).history.pushState({}, "", "/middle");
|
|
1717
|
+
(globalThis as any).location.href = "https://plain.test/next";
|
|
1718
|
+
(globalThis as any).location.pathname = "/next";
|
|
1719
|
+
(globalThis as any).history.pushState({}, "", "/next");
|
|
1720
|
+
|
|
1721
|
+
expect(shopifyPublish).not.toHaveBeenCalled();
|
|
1722
|
+
expect(getAssignments().some((assignment) => assignment.experimentId === "exp_spa_held_sync")).toBe(false);
|
|
1723
|
+
|
|
1724
|
+
release();
|
|
1725
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1726
|
+
|
|
1727
|
+
expect(getAssignments()).toEqual(expect.arrayContaining([
|
|
1728
|
+
expect.objectContaining({ experimentId: "exp_spa_held_sync", variationId: "var_spa_held_sync" }),
|
|
1729
|
+
]));
|
|
1730
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
1731
|
+
"drip_assignment_sync",
|
|
1732
|
+
expect.objectContaining({
|
|
1733
|
+
assignments: expect.arrayContaining([
|
|
1734
|
+
expect.objectContaining({ experimentId: "exp_spa_held_sync", variationId: "var_spa_held_sync" }),
|
|
1735
|
+
]),
|
|
1736
|
+
}),
|
|
1737
|
+
);
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
it("defers initial Shopify assignment sync until hydration applies the treatment", async () => {
|
|
1742
|
+
const shopifyPublish = vi.fn();
|
|
1743
|
+
const title = { textContent: "Original" };
|
|
1744
|
+
installMutationObserverStub();
|
|
1745
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
1746
|
+
selector === "#product-title" ? [title] : []
|
|
1747
|
+
));
|
|
1748
|
+
(globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
|
|
1749
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1750
|
+
const { hydrated, init } = await import("./index");
|
|
1751
|
+
|
|
1752
|
+
init({
|
|
1753
|
+
antiFlickerMode: "off",
|
|
1754
|
+
endpoint: "https://events.test",
|
|
1755
|
+
experiments: [{
|
|
1756
|
+
id: "exp_hydration_shopify_sync",
|
|
1757
|
+
status: "running",
|
|
1758
|
+
trafficAllocation: 1,
|
|
1759
|
+
variations: [{
|
|
1760
|
+
id: "var_hydration_shopify_sync",
|
|
1761
|
+
weight: 1,
|
|
1762
|
+
mutations: [{ selector: "#product-title", action: "text", text: "Treatment" }],
|
|
1763
|
+
}],
|
|
1764
|
+
}],
|
|
1765
|
+
shopId: "shop_123",
|
|
1766
|
+
spa: { applyTiming: "after_hydration", hydrationTimeoutMs: 3_000 },
|
|
1767
|
+
trackingStart: "immediate",
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
expect(title.textContent).toBe("Original");
|
|
1771
|
+
expect(shopifyPublish).not.toHaveBeenCalledWith("drip_assignment_sync", expect.any(Object));
|
|
1772
|
+
|
|
1773
|
+
hydrated();
|
|
1774
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1775
|
+
|
|
1776
|
+
expect(title.textContent).toBe("Treatment");
|
|
1777
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
1778
|
+
"drip_assignment_sync",
|
|
1779
|
+
expect.objectContaining({
|
|
1780
|
+
assignments: [expect.objectContaining({
|
|
1781
|
+
experimentId: "exp_hydration_shopify_sync",
|
|
1782
|
+
variationId: "var_hydration_shopify_sync",
|
|
1783
|
+
})],
|
|
1784
|
+
}),
|
|
1785
|
+
);
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
it("writes the server-readable drip_ctx cookie after experiment exposure", async () => {
|
|
1790
|
+
const cookieWrites = installCookieJar();
|
|
1791
|
+
(globalThis as any).sessionStorage.setItem("drip_sid", "session_ctx");
|
|
1792
|
+
const { init } = await import("./index");
|
|
1793
|
+
|
|
1794
|
+
init({
|
|
1795
|
+
antiFlickerMode: "off",
|
|
1796
|
+
endpoint: "https://events.test",
|
|
1797
|
+
experiments: [
|
|
1798
|
+
{
|
|
1799
|
+
id: "exp_ctx_a",
|
|
1800
|
+
status: "running",
|
|
1801
|
+
trafficAllocation: 1,
|
|
1802
|
+
variations: [{ id: "var_ctx_a", weight: 1, mutations: [] }],
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
id: "exp_ctx_b",
|
|
1806
|
+
status: "running",
|
|
1807
|
+
trafficAllocation: 1,
|
|
1808
|
+
variations: [{ id: "var_ctx_b", weight: 1, mutations: [] }],
|
|
1809
|
+
},
|
|
1810
|
+
],
|
|
1811
|
+
shopId: "shop_123",
|
|
1812
|
+
trackingStart: "immediate",
|
|
1813
|
+
userId: "visitor_ctx",
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const raw = readCookieValue("drip_ctx");
|
|
1817
|
+
expect(raw).toBeTruthy();
|
|
1818
|
+
expect(raw!.length).toBeLessThanOrEqual(1024);
|
|
1819
|
+
const contextWrite = cookieWrites.find((write) => write.startsWith("drip_ctx="));
|
|
1820
|
+
expect(contextWrite).toContain("Path=/");
|
|
1821
|
+
expect(contextWrite).toContain("SameSite=Lax");
|
|
1822
|
+
expect(contextWrite).toContain("Max-Age=2592000");
|
|
1823
|
+
expect(contextWrite).not.toContain("Secure");
|
|
1824
|
+
expect(readDecodedDripContext()).toBe(
|
|
1825
|
+
"1|visitor_ctx|session_ctx|exp_ctx_a:var_ctx_a,exp_ctx_b:var_ctx_b",
|
|
1826
|
+
);
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
it("writes drip_ctx during init before idle analytics starts", async () => {
|
|
1831
|
+
installCookieJar();
|
|
1832
|
+
(globalThis as any).sessionStorage.setItem("drip_sid", "session_ctx");
|
|
1833
|
+
const { init } = await import("./index");
|
|
1834
|
+
|
|
1835
|
+
init({
|
|
1836
|
+
antiFlickerMode: "off",
|
|
1837
|
+
endpoint: "https://events.test",
|
|
1838
|
+
experiments: [],
|
|
1839
|
+
shopId: "shop_123",
|
|
1840
|
+
trackingStart: "idle",
|
|
1841
|
+
userId: "visitor_ctx",
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
1845
|
+
expect(readDecodedDripContext()).toBe("1|visitor_ctx|session_ctx|");
|
|
1846
|
+
});
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
it("caps drip_ctx at 1024 encoded bytes with the most recent deduped exposures", async () => {
|
|
1850
|
+
installCookieJar();
|
|
1851
|
+
const now = Date.now();
|
|
1852
|
+
(globalThis as any).sessionStorage.setItem("drip_sid", "session_ctx");
|
|
1853
|
+
(globalThis as any).localStorage.setItem(
|
|
1854
|
+
"drip_exposures",
|
|
1855
|
+
JSON.stringify([
|
|
1856
|
+
{ e: "exp_dup", v: "var_old", t: now - 20_000 },
|
|
1857
|
+
...Array.from({ length: 12 }, (_, index) => ({
|
|
1858
|
+
e: `exp_${index}`,
|
|
1859
|
+
v: `var_${index}_${"x".repeat(130)}`,
|
|
1860
|
+
t: now - (12 - index) * 1000,
|
|
1861
|
+
})),
|
|
1862
|
+
{ e: "exp_dup", v: "var_new", t: now + 1000 },
|
|
1863
|
+
]),
|
|
1864
|
+
);
|
|
1865
|
+
const { init } = await import("./index");
|
|
1866
|
+
|
|
1867
|
+
init({
|
|
1868
|
+
antiFlickerMode: "off",
|
|
1869
|
+
endpoint: "https://events.test",
|
|
1870
|
+
experiments: [],
|
|
1871
|
+
shopId: "shop_123",
|
|
1872
|
+
trackingStart: "immediate",
|
|
1873
|
+
userId: "visitor_ctx",
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
const raw = readCookieValue("drip_ctx");
|
|
1877
|
+
expect(raw).toBeTruthy();
|
|
1878
|
+
expect(raw!.length).toBeLessThanOrEqual(1024);
|
|
1879
|
+
const exposurePairs = readDecodedDripContext()?.split("|")[3]?.split(",").filter(Boolean) ?? [];
|
|
1880
|
+
expect(exposurePairs.length).toBeLessThanOrEqual(10);
|
|
1881
|
+
expect(exposurePairs).toContain("exp_dup:var_new");
|
|
1882
|
+
expect(exposurePairs).not.toContain("exp_dup:var_old");
|
|
1883
|
+
expect(exposurePairs.some((pair) => pair.startsWith("exp_0:"))).toBe(false);
|
|
1884
|
+
expect(exposurePairs.some((pair) => pair.startsWith("exp_1:"))).toBe(false);
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
|
|
1888
|
+
it("marks Shopify assignment sync payloads as QA traffic in QA mode", async () => {
|
|
1889
|
+
(globalThis as any).location.search = "?drip_qa=1&drip_qa_session=qa_shopify_sync";
|
|
1890
|
+
const shopifyPublish = vi.fn();
|
|
1891
|
+
(globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
|
|
1892
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1893
|
+
|
|
1894
|
+
const { getDiagnostics, init } = await import("./index");
|
|
1895
|
+
|
|
1896
|
+
init({
|
|
1897
|
+
antiFlickerMode: "off",
|
|
1898
|
+
endpoint: "https://events.test",
|
|
1899
|
+
experiments: [
|
|
1900
|
+
{
|
|
1901
|
+
id: "exp_qa_shopify",
|
|
1902
|
+
status: "running",
|
|
1903
|
+
trafficAllocation: 1,
|
|
1904
|
+
variations: [{ id: "var_qa_shopify", weight: 1, mutations: [] }],
|
|
1905
|
+
},
|
|
1906
|
+
],
|
|
1907
|
+
shopId: "shop_123",
|
|
1908
|
+
trackingStart: "immediate",
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
1912
|
+
"drip_assignment_sync",
|
|
1913
|
+
expect.objectContaining({
|
|
1914
|
+
qaMode: true,
|
|
1915
|
+
qaSessionId: "qa_shopify_sync",
|
|
1916
|
+
}),
|
|
1917
|
+
);
|
|
1918
|
+
expect(getDiagnostics().spa.lastShopifyAssignmentSync).toMatchObject({
|
|
1919
|
+
status: "sent",
|
|
1920
|
+
assignmentCount: 1,
|
|
1921
|
+
});
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
it("republishes the assignment bridge when the Shopify cart token becomes known", async () => {
|
|
1926
|
+
const shopifyPublish = vi.fn();
|
|
1927
|
+
(globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
|
|
1928
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1929
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
1930
|
+
if (input === "/cart/update.js") {
|
|
1931
|
+
return new Response(JSON.stringify({ token: "cart-token?key=private" }), {
|
|
1932
|
+
status: 200,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
return new Response("{}", { status: 202 });
|
|
1936
|
+
}) as any;
|
|
1937
|
+
|
|
1938
|
+
const { init } = await import("./index");
|
|
1939
|
+
init({
|
|
1940
|
+
antiFlickerMode: "off",
|
|
1941
|
+
endpoint: "https://events.test",
|
|
1942
|
+
experiments: [{
|
|
1943
|
+
id: "exp_cart_token_bridge",
|
|
1944
|
+
status: "running",
|
|
1945
|
+
trafficAllocation: 1,
|
|
1946
|
+
variations: [{ id: "var_cart_token_bridge", weight: 1, mutations: [] }],
|
|
1947
|
+
}],
|
|
1948
|
+
shopId: "shop_123",
|
|
1949
|
+
trackingStart: "immediate",
|
|
1950
|
+
userId: "visitor_cart_token_bridge",
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
await vi.waitFor(() => {
|
|
1954
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
1955
|
+
"drip_assignment_sync",
|
|
1956
|
+
expect.objectContaining({ cartToken: "cart-token" }),
|
|
1957
|
+
);
|
|
1958
|
+
});
|
|
1959
|
+
const payloads = shopifyPublish.mock.calls
|
|
1960
|
+
.filter(([name]) => name === "drip_assignment_sync")
|
|
1961
|
+
.map(([, payload]) => payload as Record<string, unknown>);
|
|
1962
|
+
expect(payloads.some((payload) => !Object.hasOwn(payload, "cartToken"))).toBe(true);
|
|
1963
|
+
expect(payloads.at(-1)).toMatchObject({ cartToken: "cart-token" });
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
it.each([
|
|
1968
|
+
[true, true, "holdout"],
|
|
1969
|
+
[true, false, "active"],
|
|
1970
|
+
[false, false, undefined],
|
|
1971
|
+
] as const)("publishes the two-sided Shopify cohort when enabled=%s holdout=%s", async (enabled, shouldHoldout, expectedCohort) => {
|
|
1972
|
+
const shopifyPublish = vi.fn();
|
|
1973
|
+
(globalThis as any).window.Shopify = { analytics: { publish: shopifyPublish } };
|
|
1974
|
+
(globalThis as any).Shopify = (globalThis as any).window.Shopify;
|
|
1975
|
+
|
|
1976
|
+
const { init } = await import("./index");
|
|
1977
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
1978
|
+
const userId = Array.from({ length: 1000 }, (_, index) => `cohort-${index}`).find((id) =>
|
|
1979
|
+
isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id) === shouldHoldout,
|
|
1980
|
+
);
|
|
1981
|
+
expect(userId).toBeTruthy();
|
|
1982
|
+
|
|
1983
|
+
init({
|
|
1984
|
+
antiFlickerMode: "off",
|
|
1985
|
+
endpoint: "https://events.test",
|
|
1986
|
+
experiments: [],
|
|
1987
|
+
holdout_config_updated_at: "2026-07-27T08:30:00.000Z",
|
|
1988
|
+
holdout_v1_enabled: enabled,
|
|
1989
|
+
holdout_pct: 10,
|
|
1990
|
+
shopId: "shop_123",
|
|
1991
|
+
trackingStart: "immediate",
|
|
1992
|
+
userId: userId!,
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
expect(shopifyPublish).toHaveBeenCalledWith(
|
|
1996
|
+
"drip_assignment_sync",
|
|
1997
|
+
expect.objectContaining({
|
|
1998
|
+
cohort: expectedCohort,
|
|
1999
|
+
holdoutConfigEpoch: Date.parse("2026-07-27T08:30:00.000Z"),
|
|
2000
|
+
}),
|
|
2001
|
+
);
|
|
2002
|
+
});
|
|
2003
|
+
});
|