@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
package/src/shopify-cart.test.ts
CHANGED
|
@@ -24,12 +24,277 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
afterEach(() => {
|
|
27
|
+
vi.useRealTimers();
|
|
27
28
|
vi.restoreAllMocks();
|
|
28
29
|
(globalThis as any).window = originalWindow;
|
|
29
30
|
(globalThis as any).sessionStorage = originalSessionStorage;
|
|
30
31
|
globalThis.fetch = originalFetch;
|
|
31
32
|
});
|
|
32
33
|
|
|
34
|
+
it("serializes cross-signature writes so the newest state is submitted last", async () => {
|
|
35
|
+
let resolveFirst!: (response: Response) => void;
|
|
36
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
|
|
37
|
+
resolveFirst = resolve;
|
|
38
|
+
})) as any;
|
|
39
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
40
|
+
const first = syncShopifyCartAttributes({
|
|
41
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_old" }],
|
|
42
|
+
visitorId: "visitor_123",
|
|
43
|
+
sessionId: "session_old",
|
|
44
|
+
});
|
|
45
|
+
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
|
|
46
|
+
const newest = syncShopifyCartAttributes({
|
|
47
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_new" }],
|
|
48
|
+
visitorId: "visitor_123",
|
|
49
|
+
sessionId: "session_new",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
53
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
54
|
+
resolveFirst(new Response("{}", { status: 200 }));
|
|
55
|
+
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
|
|
56
|
+
const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
|
|
57
|
+
expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([
|
|
58
|
+
{ e: "exp_123", v: "var_new" },
|
|
59
|
+
]);
|
|
60
|
+
expect(finalBody.attributes.apex_session_id).toBe("session_new");
|
|
61
|
+
resolveFirst(new Response("{}", { status: 200 }));
|
|
62
|
+
await expect(Promise.all([first, newest])).resolves.toEqual([
|
|
63
|
+
{ status: "skipped", reason: "superseded" },
|
|
64
|
+
{ status: "sent" },
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("coalesces overlapping mutations into exactly one newest trailing write", async () => {
|
|
69
|
+
let resolveWrite!: (response: Response) => void;
|
|
70
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
|
|
71
|
+
resolveWrite = resolve;
|
|
72
|
+
})) as any;
|
|
73
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
74
|
+
const inputs = ["old", "middle", "newest"].map((variationId) => ({
|
|
75
|
+
assignments: [{ experimentId: "exp_123", variationId }],
|
|
76
|
+
visitorId: "visitor_123",
|
|
77
|
+
sessionId: `session_${variationId}`,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const writes = [syncShopifyCartAttributes(inputs[0]!)];
|
|
81
|
+
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
|
|
82
|
+
writes.push(syncShopifyCartAttributes(inputs[1]!));
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
writes.push(syncShopifyCartAttributes(inputs[2]!));
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
86
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
87
|
+
resolveWrite(new Response("{}", { status: 200 }));
|
|
88
|
+
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
|
|
89
|
+
expect(JSON.parse(
|
|
90
|
+
JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body))
|
|
91
|
+
.attributes.apex_assignments,
|
|
92
|
+
)).toEqual([{ e: "exp_123", v: "newest" }]);
|
|
93
|
+
resolveWrite(new Response("{}", { status: 200 }));
|
|
94
|
+
await expect(Promise.all(writes)).resolves.toEqual([
|
|
95
|
+
{ status: "skipped", reason: "superseded" },
|
|
96
|
+
{ status: "skipped", reason: "superseded" },
|
|
97
|
+
{ status: "sent" },
|
|
98
|
+
]);
|
|
99
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("atomically drains a write queued in the writer's exit microtask window", async () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
let resolveWrite!: (response: Response) => void;
|
|
105
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
|
|
106
|
+
resolveWrite = resolve;
|
|
107
|
+
})) as any;
|
|
108
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
109
|
+
const input = {
|
|
110
|
+
assignments: [{ experimentId: "exp_123", variationId: "treatment" }],
|
|
111
|
+
visitorId: "visitor_123",
|
|
112
|
+
sessionId: "session_123",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const first = syncShopifyCartAttributes(input);
|
|
116
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
117
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
118
|
+
let reissued!: ReturnType<typeof syncShopifyCartAttributes>;
|
|
119
|
+
queueMicrotask(() => {
|
|
120
|
+
reissued = syncShopifyCartAttributes(input, { force: true });
|
|
121
|
+
});
|
|
122
|
+
resolveWrite(new Response("{}", { status: 200 }));
|
|
123
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
124
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
125
|
+
resolveWrite(new Response("{}", { status: 200 }));
|
|
126
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
127
|
+
|
|
128
|
+
await expect(Promise.all([first, reissued])).resolves.toEqual([
|
|
129
|
+
{ status: "skipped", reason: "superseded" },
|
|
130
|
+
{ status: "sent" },
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("discards a queued cart write when sync state is invalidated", async () => {
|
|
135
|
+
vi.useFakeTimers();
|
|
136
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>(() => {})) as any;
|
|
137
|
+
const {
|
|
138
|
+
invalidateShopifyCartAttributeSync,
|
|
139
|
+
syncShopifyCartAttributes,
|
|
140
|
+
} = await import("./shopify-cart");
|
|
141
|
+
|
|
142
|
+
const active = syncShopifyCartAttributes({
|
|
143
|
+
assignments: [{ experimentId: "exp_123", variationId: "old" }],
|
|
144
|
+
visitorId: "visitor_123",
|
|
145
|
+
sessionId: "session_old",
|
|
146
|
+
});
|
|
147
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
148
|
+
const queued = syncShopifyCartAttributes({
|
|
149
|
+
assignments: [{ experimentId: "exp_123", variationId: "queued" }],
|
|
150
|
+
visitorId: "visitor_123",
|
|
151
|
+
sessionId: "session_queued",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
invalidateShopifyCartAttributeSync();
|
|
155
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
156
|
+
|
|
157
|
+
await expect(queued).resolves.toEqual({ status: "skipped", reason: "superseded" });
|
|
158
|
+
await expect(active).resolves.toEqual({ status: "failed", reason: "cart_write_aborted" });
|
|
159
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("discards an ABA re-issue scheduled before sync state is invalidated", async () => {
|
|
163
|
+
vi.useFakeTimers();
|
|
164
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>(() => {})) as any;
|
|
165
|
+
const {
|
|
166
|
+
invalidateShopifyCartAttributeSync,
|
|
167
|
+
syncShopifyCartAttributes,
|
|
168
|
+
} = await import("./shopify-cart");
|
|
169
|
+
const input = (variationId: string) => ({
|
|
170
|
+
assignments: [{ experimentId: "exp_123", variationId }],
|
|
171
|
+
visitorId: "visitor_123",
|
|
172
|
+
sessionId: `session_${variationId}`,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const firstA = syncShopifyCartAttributes(input("a"));
|
|
176
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
177
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
178
|
+
const middleB = syncShopifyCartAttributes(input("b"));
|
|
179
|
+
const finalA = syncShopifyCartAttributes(input("a"));
|
|
180
|
+
|
|
181
|
+
invalidateShopifyCartAttributeSync();
|
|
182
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
183
|
+
|
|
184
|
+
await expect(firstA).resolves.toEqual({ status: "failed", reason: "cart_write_aborted" });
|
|
185
|
+
await expect(middleB).resolves.toEqual({ status: "skipped", reason: "superseded" });
|
|
186
|
+
await expect(finalA).resolves.toEqual({ status: "skipped", reason: "superseded" });
|
|
187
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("times out a stalled write and lets the newest queued sync proceed", async () => {
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
globalThis.fetch = vi.fn()
|
|
193
|
+
.mockImplementationOnce(() => new Promise<Response>(() => {}))
|
|
194
|
+
.mockResolvedValueOnce(new Response("{}", { status: 200 })) as any;
|
|
195
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
196
|
+
|
|
197
|
+
const stalled = syncShopifyCartAttributes({
|
|
198
|
+
assignments: [{ experimentId: "exp_123", variationId: "stalled" }],
|
|
199
|
+
visitorId: "visitor_123",
|
|
200
|
+
sessionId: "session_stalled",
|
|
201
|
+
});
|
|
202
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
203
|
+
const newest = syncShopifyCartAttributes({
|
|
204
|
+
assignments: [{ experimentId: "exp_123", variationId: "newest" }],
|
|
205
|
+
visitorId: "visitor_123",
|
|
206
|
+
sessionId: "session_newest",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
210
|
+
await expect(stalled).resolves.toEqual({ status: "failed", reason: "cart_write_timeout" });
|
|
211
|
+
await expect(newest).resolves.toEqual({ status: "sent" });
|
|
212
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("classifies an AbortError caused by the writer deadline as timeout", async () => {
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
globalThis.fetch = vi.fn((_url, init) => new Promise<Response>((_resolve, reject) => {
|
|
218
|
+
init?.signal?.addEventListener("abort", () => {
|
|
219
|
+
reject(new DOMException("ambient abort text", "AbortError"));
|
|
220
|
+
}, { once: true });
|
|
221
|
+
})) as any;
|
|
222
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
223
|
+
|
|
224
|
+
const write = syncShopifyCartAttributes({
|
|
225
|
+
assignments: [{ experimentId: "exp_123", variationId: "stalled" }],
|
|
226
|
+
visitorId: "visitor_123",
|
|
227
|
+
sessionId: "session_stalled",
|
|
228
|
+
});
|
|
229
|
+
await vi.advanceTimersByTimeAsync(10_000);
|
|
230
|
+
|
|
231
|
+
await expect(write).resolves.toEqual({ status: "failed", reason: "cart_write_timeout" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("re-issues A after an intervening B even while the first A is in flight", async () => {
|
|
235
|
+
const resolvers: Array<(response: Response) => void> = [];
|
|
236
|
+
globalThis.fetch = vi.fn(() => new Promise<Response>((resolve) => {
|
|
237
|
+
resolvers.push(resolve);
|
|
238
|
+
})) as any;
|
|
239
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
240
|
+
const input = (variationId: string) => ({
|
|
241
|
+
assignments: [{ experimentId: "exp_123", variationId }],
|
|
242
|
+
visitorId: "visitor_123",
|
|
243
|
+
sessionId: `session_${variationId}`,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const firstA = syncShopifyCartAttributes(input("A"));
|
|
247
|
+
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
|
|
248
|
+
const writeB = syncShopifyCartAttributes(input("B"));
|
|
249
|
+
const finalA = syncShopifyCartAttributes(input("A"));
|
|
250
|
+
|
|
251
|
+
resolvers[0]!(new Response("{}", { status: 200 }));
|
|
252
|
+
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
|
|
253
|
+
resolvers[1]!(new Response("{}", { status: 200 }));
|
|
254
|
+
await vi.waitFor(() => expect(resolvers).toHaveLength(3));
|
|
255
|
+
const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[2]?.[1]?.body));
|
|
256
|
+
expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([{ e: "exp_123", v: "A" }]);
|
|
257
|
+
resolvers[2]!(new Response("{}", { status: 200 }));
|
|
258
|
+
|
|
259
|
+
await expect(Promise.all([firstA, writeB, finalA])).resolves.toEqual([
|
|
260
|
+
{ status: "skipped", reason: "superseded" },
|
|
261
|
+
{ status: "skipped", reason: "superseded" },
|
|
262
|
+
{ status: "sent" },
|
|
263
|
+
]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("re-drives the newest mutation after an in-flight write fails", async () => {
|
|
267
|
+
let rejectFirst!: (error: Error) => void;
|
|
268
|
+
globalThis.fetch = vi.fn()
|
|
269
|
+
.mockImplementationOnce(() => new Promise<Response>((_resolve, reject) => {
|
|
270
|
+
rejectFirst = reject;
|
|
271
|
+
}))
|
|
272
|
+
.mockResolvedValueOnce(new Response("{}", { status: 200 })) as any;
|
|
273
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
274
|
+
const first = syncShopifyCartAttributes({
|
|
275
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_old" }],
|
|
276
|
+
visitorId: "visitor_123",
|
|
277
|
+
sessionId: "session_old",
|
|
278
|
+
});
|
|
279
|
+
await vi.waitFor(() => expect(rejectFirst).toBeTypeOf("function"));
|
|
280
|
+
const newest = syncShopifyCartAttributes({
|
|
281
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_new" }],
|
|
282
|
+
visitorId: "visitor_123",
|
|
283
|
+
sessionId: "session_new",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
rejectFirst(new Error("cart unavailable"));
|
|
287
|
+
await expect(Promise.all([first, newest])).resolves.toEqual([
|
|
288
|
+
{ status: "failed", reason: "cart unavailable" },
|
|
289
|
+
{ status: "sent" },
|
|
290
|
+
]);
|
|
291
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
292
|
+
const finalBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body));
|
|
293
|
+
expect(JSON.parse(finalBody.attributes.apex_assignments)).toEqual([
|
|
294
|
+
{ e: "exp_123", v: "var_new" },
|
|
295
|
+
]);
|
|
296
|
+
});
|
|
297
|
+
|
|
33
298
|
it("posts all Apex assignments plus primary attribution keys to the Shopify cart", async () => {
|
|
34
299
|
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
35
300
|
|
|
@@ -81,7 +346,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
81
346
|
});
|
|
82
347
|
|
|
83
348
|
it("builds ordered Storefront API cart attribute entries with compact assignment keys", async () => {
|
|
84
|
-
const { buildCartAttributeEntries } = await import("./shopify-cart");
|
|
349
|
+
const { APEX_CART_ATTRIBUTE_KEYS, buildCartAttributeEntries } = await import("./shopify-cart");
|
|
85
350
|
|
|
86
351
|
const entries = buildCartAttributeEntries({
|
|
87
352
|
assignments: [
|
|
@@ -101,6 +366,15 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
101
366
|
"apex_visitor_id",
|
|
102
367
|
"apex_session_id",
|
|
103
368
|
]);
|
|
369
|
+
expect(entries.every((entry) => APEX_CART_ATTRIBUTE_KEYS.has(entry.key))).toBe(true);
|
|
370
|
+
expect(
|
|
371
|
+
buildCartAttributeEntries({
|
|
372
|
+
assignments: [],
|
|
373
|
+
identityOnly: true,
|
|
374
|
+
visitorId: "visitor_123",
|
|
375
|
+
sessionId: "session_123",
|
|
376
|
+
}).every((entry) => APEX_CART_ATTRIBUTE_KEYS.has(entry.key)),
|
|
377
|
+
).toBe(true);
|
|
104
378
|
expect(JSON.parse(entries[0]!.value)).toEqual([
|
|
105
379
|
{ e: "exp_123", v: "var_123", ep: 2 },
|
|
106
380
|
{ e: "exp_456", v: "var_456" },
|
|
@@ -119,6 +393,264 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
119
393
|
});
|
|
120
394
|
});
|
|
121
395
|
|
|
396
|
+
it("keeps the consent-pending stamp to arm labels only against the consented fixture", async () => {
|
|
397
|
+
const { buildCartAttributeEntries } = await import("./shopify-cart");
|
|
398
|
+
const fixture = {
|
|
399
|
+
assignments: [
|
|
400
|
+
{ experimentId: "exp_first", variationId: "var_first", assignmentEpoch: 4 },
|
|
401
|
+
{ experimentId: "exp_second", variationId: "var_second", assignmentEpoch: 2 },
|
|
402
|
+
],
|
|
403
|
+
visitorId: "visitor_123",
|
|
404
|
+
sessionId: "session_123",
|
|
405
|
+
};
|
|
406
|
+
const consented = buildCartAttributeEntries(fixture, "signed-envelope");
|
|
407
|
+
const sdkCartMint = buildCartAttributeEntries({ ...fixture, visitorId: "", sessionId: "" });
|
|
408
|
+
|
|
409
|
+
expect(sdkCartMint).toEqual([
|
|
410
|
+
{
|
|
411
|
+
key: "apex_assignments",
|
|
412
|
+
value: JSON.stringify([
|
|
413
|
+
{ e: "exp_first", v: "var_first", ep: 4 },
|
|
414
|
+
{ e: "exp_second", v: "var_second", ep: 2 },
|
|
415
|
+
]),
|
|
416
|
+
},
|
|
417
|
+
{ key: "apex_assignment_authorization", value: "" },
|
|
418
|
+
{ key: "apex_experiment_id", value: "" },
|
|
419
|
+
{ key: "apex_variation_id", value: "" },
|
|
420
|
+
{ key: "apex_assignment_epoch", value: "" },
|
|
421
|
+
{ key: "apex_visitor_id", value: "" },
|
|
422
|
+
{ key: "apex_session_id", value: "" },
|
|
423
|
+
]);
|
|
424
|
+
const identityKeys = consented
|
|
425
|
+
.map(({ key }) => key)
|
|
426
|
+
.filter((key) => [
|
|
427
|
+
"apex_assignment_authorization",
|
|
428
|
+
"apex_visitor_id",
|
|
429
|
+
"apex_session_id",
|
|
430
|
+
].includes(key));
|
|
431
|
+
expect(identityKeys).toEqual([
|
|
432
|
+
"apex_assignment_authorization",
|
|
433
|
+
"apex_visitor_id",
|
|
434
|
+
"apex_session_id",
|
|
435
|
+
]);
|
|
436
|
+
expect(sdkCartMint.filter(({ value }) => value !== "").map(({ key }) => key)).toEqual([
|
|
437
|
+
"apex_assignments",
|
|
438
|
+
]);
|
|
439
|
+
expect(sdkCartMint.map(({ key }) => key)).toEqual(expect.arrayContaining(identityKeys));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("clears stale consented identity when a consent-pending arm stamp merges into the cart", async () => {
|
|
443
|
+
const { buildCartAttributeEntries } = await import("./shopify-cart");
|
|
444
|
+
const staleCartAttributes = {
|
|
445
|
+
apex_assignments: JSON.stringify([{ e: "exp_prior", v: "var_prior", ep: 8 }]),
|
|
446
|
+
apex_assignment_authorization: "signed-prior-treatment",
|
|
447
|
+
apex_experiment_id: "exp_prior",
|
|
448
|
+
apex_variation_id: "var_prior",
|
|
449
|
+
apex_assignment_epoch: "8",
|
|
450
|
+
apex_visitor_id: "visitor_prior",
|
|
451
|
+
apex_session_id: "session_prior",
|
|
452
|
+
};
|
|
453
|
+
const sdkCartMintEntries = buildCartAttributeEntries({
|
|
454
|
+
assignments: [{
|
|
455
|
+
experimentId: "exp_sdk_cart_mint",
|
|
456
|
+
variationId: "var_sdk_cart_mint",
|
|
457
|
+
assignmentEpoch: 3,
|
|
458
|
+
}],
|
|
459
|
+
visitorId: "",
|
|
460
|
+
sessionId: "",
|
|
461
|
+
});
|
|
462
|
+
const merged = {
|
|
463
|
+
...staleCartAttributes,
|
|
464
|
+
...Object.fromEntries(sdkCartMintEntries.map(({ key, value }) => [key, value])),
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
expect(merged).toEqual({
|
|
468
|
+
apex_assignments: JSON.stringify([{ e: "exp_sdk_cart_mint", v: "var_sdk_cart_mint", ep: 3 }]),
|
|
469
|
+
apex_assignment_authorization: "",
|
|
470
|
+
apex_experiment_id: "",
|
|
471
|
+
apex_variation_id: "",
|
|
472
|
+
apex_assignment_epoch: "",
|
|
473
|
+
apex_visitor_id: "",
|
|
474
|
+
apex_session_id: "",
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("maps non-empty cart attributes 1:1 to composite metafield keys and type metadata", async () => {
|
|
479
|
+
const { buildCartAttributeEntries, buildCartMetafieldInputs } = await import("./shopify-cart");
|
|
480
|
+
const input = {
|
|
481
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_123", assignmentEpoch: 2 }],
|
|
482
|
+
visitorId: "visitor_123",
|
|
483
|
+
sessionId: "session_123",
|
|
484
|
+
};
|
|
485
|
+
const attributes = buildCartAttributeEntries(input);
|
|
486
|
+
const metafields = buildCartMetafieldInputs(input);
|
|
487
|
+
|
|
488
|
+
const nonEmptyAttributes = attributes.filter(({ value }) => value !== "");
|
|
489
|
+
expect(metafields.map(({ key, value }) => ({
|
|
490
|
+
key: key.replace(/^apex\./, ""),
|
|
491
|
+
value,
|
|
492
|
+
}))).toEqual(nonEmptyAttributes);
|
|
493
|
+
expect(metafields.every((entry) => entry.key.startsWith("apex."))).toBe(true);
|
|
494
|
+
expect(metafields.map((entry) => entry.type)).toEqual([
|
|
495
|
+
"json",
|
|
496
|
+
...Array(nonEmptyAttributes.length - 1).fill("single_line_text_field"),
|
|
497
|
+
]);
|
|
498
|
+
expect(buildCartMetafieldInputs(input, "merchant_bi").every(
|
|
499
|
+
(entry) => entry.key.startsWith("merchant_bi."),
|
|
500
|
+
)).toBe(true);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("omits cart attributes whose metafield values would be empty", async () => {
|
|
504
|
+
const { buildCartMetafieldInputs } = await import("./shopify-cart");
|
|
505
|
+
const metafields = buildCartMetafieldInputs({
|
|
506
|
+
assignments: [{ experimentId: "exp_123", variationId: "var_123" }],
|
|
507
|
+
visitorId: "visitor_123",
|
|
508
|
+
sessionId: "session_123",
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(metafields.some(({ value }) => value === "")).toBe(false);
|
|
512
|
+
expect(metafields.map(({ key }) => key)).not.toContain("apex.apex_assignment_authorization");
|
|
513
|
+
expect(metafields.map(({ key }) => key)).not.toContain("apex.apex_assignment_epoch");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("returns the complement of the current cart-metafield mirror", async () => {
|
|
517
|
+
const { getCartMetafieldStaleKeys } = await import("./shopify-cart");
|
|
518
|
+
|
|
519
|
+
expect(getCartMetafieldStaleKeys([
|
|
520
|
+
{ key: "merchant_bi.apex_assignments" },
|
|
521
|
+
{ key: "merchant_bi.apex_experiment_id" },
|
|
522
|
+
{ key: "merchant_bi.apex_visitor_id" },
|
|
523
|
+
], "merchant_bi")).toEqual([
|
|
524
|
+
"merchant_bi.apex_assignment_authorization",
|
|
525
|
+
"merchant_bi.apex_variation_id",
|
|
526
|
+
"merchant_bi.apex_assignment_epoch",
|
|
527
|
+
"merchant_bi.apex_session_id",
|
|
528
|
+
]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("returns every cart-metafield key when the current mirror is empty", async () => {
|
|
532
|
+
const { getCartMetafieldStaleKeys } = await import("./shopify-cart");
|
|
533
|
+
|
|
534
|
+
expect(getCartMetafieldStaleKeys([])).toEqual([
|
|
535
|
+
"apex.apex_assignments",
|
|
536
|
+
"apex.apex_assignment_authorization",
|
|
537
|
+
"apex.apex_experiment_id",
|
|
538
|
+
"apex.apex_variation_id",
|
|
539
|
+
"apex.apex_assignment_epoch",
|
|
540
|
+
"apex.apex_visitor_id",
|
|
541
|
+
"apex.apex_session_id",
|
|
542
|
+
]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("returns no stale keys when the current cart-metafield mirror is full", async () => {
|
|
546
|
+
const { APEX_CART_ATTRIBUTE_KEYS, getCartMetafieldStaleKeys } = await import("./shopify-cart");
|
|
547
|
+
const fullMirror = Array.from(APEX_CART_ATTRIBUTE_KEYS, (key) => ({ key: `apex.${key}` }));
|
|
548
|
+
|
|
549
|
+
expect(getCartMetafieldStaleKeys(fullMirror)).toEqual([]);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("builds only the visitor cart metafield for identity-only input", async () => {
|
|
553
|
+
const { buildCartMetafieldInputs } = await import("./shopify-cart");
|
|
554
|
+
expect(buildCartMetafieldInputs({
|
|
555
|
+
assignments: [],
|
|
556
|
+
identityOnly: true,
|
|
557
|
+
visitorId: "visitor_123",
|
|
558
|
+
sessionId: "session_123",
|
|
559
|
+
})).toEqual([{
|
|
560
|
+
key: "apex.apex_visitor_id",
|
|
561
|
+
type: "single_line_text_field",
|
|
562
|
+
value: "visitor_123",
|
|
563
|
+
}]);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("does not let an identity-only stamp suppress a later holdout control clear", async () => {
|
|
567
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
568
|
+
const identity = {
|
|
569
|
+
assignments: [],
|
|
570
|
+
visitorId: "visitor_123",
|
|
571
|
+
sessionId: "session_123",
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
await expect(syncShopifyCartAttributes({
|
|
575
|
+
...identity,
|
|
576
|
+
identityOnly: true,
|
|
577
|
+
})).resolves.toEqual({ status: "sent" });
|
|
578
|
+
await expect(syncShopifyCartAttributes({
|
|
579
|
+
...identity,
|
|
580
|
+
controlStamp: true,
|
|
581
|
+
})).resolves.toEqual({ status: "sent" });
|
|
582
|
+
|
|
583
|
+
const writes = vi.mocked(globalThis.fetch).mock.calls.map(([, init]) =>
|
|
584
|
+
JSON.parse(String(init?.body)).attributes
|
|
585
|
+
);
|
|
586
|
+
expect(writes).toEqual([
|
|
587
|
+
{ apex_visitor_id: "visitor_123" },
|
|
588
|
+
{
|
|
589
|
+
apex_assignments: "[]",
|
|
590
|
+
apex_assignment_authorization: "",
|
|
591
|
+
apex_experiment_id: "",
|
|
592
|
+
apex_variation_id: "",
|
|
593
|
+
apex_assignment_epoch: "",
|
|
594
|
+
apex_visitor_id: "visitor_123",
|
|
595
|
+
apex_session_id: "session_123",
|
|
596
|
+
},
|
|
597
|
+
]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("stops cart writes for the session after Shopify returns 405", async () => {
|
|
601
|
+
globalThis.fetch = vi.fn()
|
|
602
|
+
.mockResolvedValueOnce(new Response("method not allowed", { status: 405 })) as any;
|
|
603
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
604
|
+
const input = {
|
|
605
|
+
assignments: [{ experimentId: "exp_405", variationId: "var_405" }],
|
|
606
|
+
visitorId: "visitor_405",
|
|
607
|
+
sessionId: "session_405",
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
|
|
611
|
+
status: "failed",
|
|
612
|
+
reason: "cart_update_405",
|
|
613
|
+
});
|
|
614
|
+
await expect(syncShopifyCartAttributes({
|
|
615
|
+
...input,
|
|
616
|
+
sessionId: "session_after_405",
|
|
617
|
+
})).resolves.toEqual({
|
|
618
|
+
status: "skipped",
|
|
619
|
+
reason: "cart_update_405_circuit_break",
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it("keeps the 405 circuit breaker active when sessionStorage throws", async () => {
|
|
626
|
+
(globalThis as any).sessionStorage = {
|
|
627
|
+
getItem: () => { throw new Error("storage blocked"); },
|
|
628
|
+
setItem: () => { throw new Error("storage blocked"); },
|
|
629
|
+
};
|
|
630
|
+
globalThis.fetch = vi.fn(async () =>
|
|
631
|
+
new Response("method not allowed", { status: 405 })) as any;
|
|
632
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
633
|
+
const input = {
|
|
634
|
+
assignments: [{ experimentId: "exp_405_blocked_storage", variationId: "var_405" }],
|
|
635
|
+
visitorId: "visitor_405",
|
|
636
|
+
sessionId: "session_405",
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
await expect(syncShopifyCartAttributes(input)).resolves.toEqual({
|
|
640
|
+
status: "failed",
|
|
641
|
+
reason: "cart_update_405",
|
|
642
|
+
});
|
|
643
|
+
await expect(syncShopifyCartAttributes({
|
|
644
|
+
...input,
|
|
645
|
+
sessionId: "session_after_405",
|
|
646
|
+
})).resolves.toEqual({
|
|
647
|
+
status: "skipped",
|
|
648
|
+
reason: "cart_update_405_circuit_break",
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
652
|
+
});
|
|
653
|
+
|
|
122
654
|
it("keeps ordinary cart attribution best-effort when assignment proof is unavailable", async () => {
|
|
123
655
|
globalThis.fetch = vi.fn()
|
|
124
656
|
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
|
@@ -486,8 +1018,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
486
1018
|
assignments: [],
|
|
487
1019
|
}));
|
|
488
1020
|
await expect(older).resolves.toEqual({
|
|
489
|
-
status: "
|
|
490
|
-
reason: "
|
|
1021
|
+
status: "skipped",
|
|
1022
|
+
reason: "superseded",
|
|
491
1023
|
});
|
|
492
1024
|
|
|
493
1025
|
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
|
|
@@ -1249,6 +1781,170 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1249
1781
|
});
|
|
1250
1782
|
});
|
|
1251
1783
|
|
|
1784
|
+
it("keeps page-inactive attribution out of authoritative price adoption", async () => {
|
|
1785
|
+
const token = JSON.stringify({
|
|
1786
|
+
v: 2,
|
|
1787
|
+
p: {
|
|
1788
|
+
price: {
|
|
1789
|
+
v: 1,
|
|
1790
|
+
r: 3,
|
|
1791
|
+
c: "b".repeat(64),
|
|
1792
|
+
x: "2026-07-20",
|
|
1793
|
+
cartHash: "c".repeat(64),
|
|
1794
|
+
a: [{ e: "exp_price", v: "var_treatment", ep: 2 }],
|
|
1795
|
+
s: "d".repeat(64),
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
1798
|
+
});
|
|
1799
|
+
globalThis.fetch = vi.fn()
|
|
1800
|
+
.mockResolvedValueOnce(Response.json({
|
|
1801
|
+
assignments: [{
|
|
1802
|
+
experiment_id: "exp_price",
|
|
1803
|
+
variation_id: "var_treatment",
|
|
1804
|
+
assignment_epoch: 2,
|
|
1805
|
+
commerce_price_only: true,
|
|
1806
|
+
}],
|
|
1807
|
+
commerce_authorization: token,
|
|
1808
|
+
}))
|
|
1809
|
+
.mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
|
|
1810
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
1811
|
+
const adoptPriceAssignments = vi.fn(() => true);
|
|
1812
|
+
|
|
1813
|
+
await expect(syncShopifyCartAttributes({
|
|
1814
|
+
assignments: [{
|
|
1815
|
+
experimentId: "exp_price",
|
|
1816
|
+
variationId: "var_treatment",
|
|
1817
|
+
assignmentEpoch: 2,
|
|
1818
|
+
pageActive: false,
|
|
1819
|
+
}],
|
|
1820
|
+
visitorId: "visitor_123",
|
|
1821
|
+
sessionId: "session_123",
|
|
1822
|
+
shopId: "shop_123",
|
|
1823
|
+
endpoint: "https://events.example.com/events",
|
|
1824
|
+
ingestSignature: "ingest-token",
|
|
1825
|
+
cartToken: "cart-token",
|
|
1826
|
+
priceAuthorizationRevision: 3,
|
|
1827
|
+
priceExperimentIds: ["exp_price"],
|
|
1828
|
+
adoptPriceAssignments,
|
|
1829
|
+
})).resolves.toEqual({ status: "sent" });
|
|
1830
|
+
|
|
1831
|
+
expect(adoptPriceAssignments).not.toHaveBeenCalled();
|
|
1832
|
+
const cartUpdateBody = JSON.parse(
|
|
1833
|
+
String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
|
|
1834
|
+
);
|
|
1835
|
+
expect(cartUpdateBody.attributes).toMatchObject({
|
|
1836
|
+
apex_assignments: JSON.stringify([{ e: "exp_price", v: "var_treatment", ep: 2 }]),
|
|
1837
|
+
apex_assignment_authorization: token,
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it("preserves page-inactive price attribution across a signed empty price plane", async () => {
|
|
1842
|
+
const token = JSON.stringify({
|
|
1843
|
+
v: 2,
|
|
1844
|
+
p: {
|
|
1845
|
+
price: {
|
|
1846
|
+
v: 1,
|
|
1847
|
+
r: 3,
|
|
1848
|
+
c: "b".repeat(64),
|
|
1849
|
+
x: "2026-07-20",
|
|
1850
|
+
cartHash: "c".repeat(64),
|
|
1851
|
+
a: [],
|
|
1852
|
+
s: "d".repeat(64),
|
|
1853
|
+
},
|
|
1854
|
+
},
|
|
1855
|
+
});
|
|
1856
|
+
globalThis.fetch = vi.fn()
|
|
1857
|
+
.mockResolvedValueOnce(Response.json({
|
|
1858
|
+
assignments: [],
|
|
1859
|
+
commerce_authorization: token,
|
|
1860
|
+
}))
|
|
1861
|
+
.mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
|
|
1862
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
1863
|
+
const adoptPriceAssignments = vi.fn(() => true);
|
|
1864
|
+
|
|
1865
|
+
await expect(syncShopifyCartAttributes({
|
|
1866
|
+
assignments: [{
|
|
1867
|
+
experimentId: "exp_price",
|
|
1868
|
+
variationId: "var_treatment",
|
|
1869
|
+
assignmentEpoch: 2,
|
|
1870
|
+
pageActive: false,
|
|
1871
|
+
}],
|
|
1872
|
+
visitorId: "visitor_123",
|
|
1873
|
+
sessionId: "session_123",
|
|
1874
|
+
shopId: "shop_123",
|
|
1875
|
+
endpoint: "https://events.example.com/events",
|
|
1876
|
+
ingestSignature: "ingest-token",
|
|
1877
|
+
cartToken: "cart-token",
|
|
1878
|
+
priceAuthorizationRevision: 3,
|
|
1879
|
+
priceExperimentIds: ["exp_price"],
|
|
1880
|
+
adoptPriceAssignments,
|
|
1881
|
+
})).resolves.toEqual({ status: "sent" });
|
|
1882
|
+
|
|
1883
|
+
expect(adoptPriceAssignments).not.toHaveBeenCalled();
|
|
1884
|
+
const cartUpdateBody = JSON.parse(
|
|
1885
|
+
String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
|
|
1886
|
+
);
|
|
1887
|
+
expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([
|
|
1888
|
+
{ e: "exp_price", v: "var_treatment", ep: 2 },
|
|
1889
|
+
]);
|
|
1890
|
+
expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe(token);
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
it("keeps a page-inactive offer pin out of adoption without duplicating attribution", async () => {
|
|
1894
|
+
const token = JSON.stringify({
|
|
1895
|
+
v: 2,
|
|
1896
|
+
p: {
|
|
1897
|
+
offers: {
|
|
1898
|
+
v: 1,
|
|
1899
|
+
a: [{ e: "exp_offer", v: "var_treatment", ep: 3 }],
|
|
1900
|
+
},
|
|
1901
|
+
},
|
|
1902
|
+
});
|
|
1903
|
+
globalThis.fetch = vi.fn()
|
|
1904
|
+
.mockResolvedValueOnce(Response.json({
|
|
1905
|
+
assignments: [{
|
|
1906
|
+
experiment_id: "exp_offer",
|
|
1907
|
+
variation_id: "var_treatment",
|
|
1908
|
+
assignment_epoch: 3,
|
|
1909
|
+
commerce_price_only: true,
|
|
1910
|
+
}],
|
|
1911
|
+
commerce_authorization: token,
|
|
1912
|
+
}))
|
|
1913
|
+
.mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
|
|
1914
|
+
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
1915
|
+
const adoptOffersAssignments = vi.fn(() => true);
|
|
1916
|
+
const canAdoptOffersAssignments = vi.fn(() => true);
|
|
1917
|
+
|
|
1918
|
+
await expect(syncShopifyCartAttributes({
|
|
1919
|
+
assignments: [{
|
|
1920
|
+
experimentId: "exp_offer",
|
|
1921
|
+
variationId: "var_treatment",
|
|
1922
|
+
assignmentEpoch: 3,
|
|
1923
|
+
pageActive: false,
|
|
1924
|
+
}],
|
|
1925
|
+
visitorId: "visitor_123",
|
|
1926
|
+
sessionId: "session_123",
|
|
1927
|
+
shopId: "shop_123",
|
|
1928
|
+
endpoint: "https://events.example.com/events",
|
|
1929
|
+
ingestSignature: "ingest-token",
|
|
1930
|
+
cartToken: "cart-token",
|
|
1931
|
+
offersAuthorizationRevision: 5,
|
|
1932
|
+
offersExperimentIds: ["exp_offer"],
|
|
1933
|
+
adoptOffersAssignments,
|
|
1934
|
+
canAdoptOffersAssignments,
|
|
1935
|
+
})).resolves.toEqual({ status: "sent" });
|
|
1936
|
+
|
|
1937
|
+
expect(canAdoptOffersAssignments).not.toHaveBeenCalled();
|
|
1938
|
+
expect(adoptOffersAssignments).not.toHaveBeenCalled();
|
|
1939
|
+
const cartUpdateBody = JSON.parse(
|
|
1940
|
+
String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
|
|
1941
|
+
);
|
|
1942
|
+
expect(JSON.parse(cartUpdateBody.attributes.apex_assignments)).toEqual([
|
|
1943
|
+
{ e: "exp_offer", v: "var_treatment", ep: 3 },
|
|
1944
|
+
]);
|
|
1945
|
+
expect(cartUpdateBody.attributes.apex_assignment_authorization).toBe(token);
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1252
1948
|
it("adopts null pins only inside the V2 evaluated set and preserves browser-only assignments", async () => {
|
|
1253
1949
|
const browserOnlyAssignment = {
|
|
1254
1950
|
experimentId: "exp_browser_only",
|
|
@@ -1437,8 +2133,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1437
2133
|
commerce_authorization: olderToken,
|
|
1438
2134
|
}));
|
|
1439
2135
|
await expect(older).resolves.toEqual({
|
|
1440
|
-
status: "
|
|
1441
|
-
reason: "
|
|
2136
|
+
status: "skipped",
|
|
2137
|
+
reason: "superseded",
|
|
1442
2138
|
});
|
|
1443
2139
|
expect(adoptPriceAssignments).toHaveBeenCalledOnce();
|
|
1444
2140
|
expect(runtimeAssignments).toEqual([{
|
|
@@ -1528,8 +2224,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1528
2224
|
commerce_authorization: nullPinToken,
|
|
1529
2225
|
}));
|
|
1530
2226
|
await expect(older).resolves.toEqual({
|
|
1531
|
-
status: "
|
|
1532
|
-
reason: "
|
|
2227
|
+
status: "skipped",
|
|
2228
|
+
reason: "superseded",
|
|
1533
2229
|
});
|
|
1534
2230
|
expect(adoptPriceAssignments).toHaveBeenCalledOnce();
|
|
1535
2231
|
expect(runtimeAssignments).toEqual([{
|
|
@@ -1915,6 +2611,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1915
2611
|
cartHash,
|
|
1916
2612
|
a: [{ e: assignment.experimentId, v: assignment.variationId, ep: assignment.assignmentEpoch }],
|
|
1917
2613
|
});
|
|
2614
|
+
const onCartToken = vi.fn();
|
|
1918
2615
|
globalThis.fetch = vi.fn((url) => {
|
|
1919
2616
|
if (url === "/cart.js") {
|
|
1920
2617
|
cartReads += 1;
|
|
@@ -1951,6 +2648,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1951
2648
|
ingestSignature: "ingest-token",
|
|
1952
2649
|
priceAuthorizationRevision: 3,
|
|
1953
2650
|
priceExperimentIds: [assignment.experimentId],
|
|
2651
|
+
onCartToken,
|
|
1954
2652
|
};
|
|
1955
2653
|
|
|
1956
2654
|
const cartA = syncShopifyCartAttributes(input);
|
|
@@ -1960,8 +2658,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1960
2658
|
|
|
1961
2659
|
resolveCartA(Response.json({ token: "cart-a?key=a" }));
|
|
1962
2660
|
await expect(cartA).resolves.toEqual({
|
|
1963
|
-
status: "
|
|
1964
|
-
reason: "
|
|
2661
|
+
status: "skipped",
|
|
2662
|
+
reason: "superseded",
|
|
1965
2663
|
});
|
|
1966
2664
|
await expect(cartB).resolves.toEqual({ status: "sent" });
|
|
1967
2665
|
|
|
@@ -1970,6 +2668,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
1970
2668
|
.filter(([url]) => url === "https://events.example.com/v1/assign")
|
|
1971
2669
|
.map(([, init]) => JSON.parse(String(init?.body)).cart_token);
|
|
1972
2670
|
expect(authorizationCartTokens).toEqual(["cart-a?key=a", "cart-b?key=b"]);
|
|
2671
|
+
expect(onCartToken).toHaveBeenCalledWith("cart-a");
|
|
2672
|
+
expect(onCartToken).toHaveBeenCalledWith("cart-b");
|
|
1973
2673
|
const cartUpdates = calls.filter(([url]) => url === "/cart/update.js");
|
|
1974
2674
|
expect(cartUpdates).toHaveLength(1);
|
|
1975
2675
|
expect(JSON.parse(String(cartUpdates[0]?.[1]?.body)).attributes)
|
|
@@ -2035,8 +2735,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2035
2735
|
|
|
2036
2736
|
resolveCartA(Response.json({ token: "cart-a?key=a" }));
|
|
2037
2737
|
await expect(cartA).resolves.toEqual({
|
|
2038
|
-
status: "
|
|
2039
|
-
reason: "
|
|
2738
|
+
status: "skipped",
|
|
2739
|
+
reason: "superseded",
|
|
2040
2740
|
});
|
|
2041
2741
|
await expect(cartB).resolves.toEqual({
|
|
2042
2742
|
status: "failed",
|
|
@@ -2163,17 +2863,18 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2163
2863
|
const first = syncShopifyCartAttributes(firstInput);
|
|
2164
2864
|
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
|
|
2165
2865
|
const second = syncShopifyCartAttributes(secondInput);
|
|
2166
|
-
await
|
|
2866
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2867
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
2167
2868
|
|
|
2869
|
+
resolveFirst(new Response("ok", { status: 200 }));
|
|
2870
|
+
await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(2));
|
|
2871
|
+
expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
|
|
2168
2872
|
resolveSecond(new Response("ok", { status: 200 }));
|
|
2169
2873
|
await expect(second).resolves.toEqual({ status: "sent" });
|
|
2874
|
+
await expect(first).resolves.toEqual({ status: "skipped", reason: "superseded" });
|
|
2170
2875
|
const newestStamp = sessionStorage.getItem("drip_cart_attr");
|
|
2171
2876
|
expect(newestStamp).toContain("exp_new");
|
|
2172
2877
|
|
|
2173
|
-
resolveFirst(new Response("ok", { status: 200 }));
|
|
2174
|
-
await expect(first).resolves.toEqual({ status: "sent" });
|
|
2175
|
-
expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
|
|
2176
|
-
|
|
2177
2878
|
vi.mocked(globalThis.fetch).mockClear();
|
|
2178
2879
|
await expect(syncShopifyCartAttributes(secondInput)).resolves.toEqual({
|
|
2179
2880
|
status: "sent",
|
|
@@ -2183,6 +2884,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2183
2884
|
});
|
|
2184
2885
|
|
|
2185
2886
|
it("does not let stale authorization mutate the Shopify cart", async () => {
|
|
2887
|
+
vi.useFakeTimers();
|
|
2186
2888
|
let resolveOlderAuthorization!: (response: Response) => void;
|
|
2187
2889
|
let resolveNewerAuthorization!: (response: Response) => void;
|
|
2188
2890
|
globalThis.fetch = vi.fn((url) => {
|
|
@@ -2210,9 +2912,11 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2210
2912
|
});
|
|
2211
2913
|
|
|
2212
2914
|
const older = syncShopifyCartAttributes(input("exp_old", "var_old", 1));
|
|
2213
|
-
await vi.
|
|
2915
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
2916
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
2214
2917
|
const newer = syncShopifyCartAttributes(input("exp_new", "var_new", 2));
|
|
2215
|
-
await vi.
|
|
2918
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
2919
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
2216
2920
|
|
|
2217
2921
|
resolveNewerAuthorization(new Response(JSON.stringify({
|
|
2218
2922
|
assignments: [{
|
|
@@ -2234,8 +2938,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2234
2938
|
commerce_authorization: "older-proof",
|
|
2235
2939
|
}), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
2236
2940
|
await expect(older).resolves.toEqual({
|
|
2237
|
-
status: "
|
|
2238
|
-
reason: "
|
|
2941
|
+
status: "skipped",
|
|
2942
|
+
reason: "superseded",
|
|
2239
2943
|
});
|
|
2240
2944
|
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
|
|
2241
2945
|
|
|
@@ -2250,6 +2954,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2250
2954
|
});
|
|
2251
2955
|
|
|
2252
2956
|
it("lets price-plane teardown invalidate an in-flight cart authorization before it writes", async () => {
|
|
2957
|
+
vi.useFakeTimers();
|
|
2253
2958
|
let resolveAuthorization!: (response: Response) => void;
|
|
2254
2959
|
globalThis.fetch = vi.fn((url) => {
|
|
2255
2960
|
if (url === "/cart.js") {
|
|
@@ -2287,9 +2992,11 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2287
2992
|
commerce_authorization: "stale-proof",
|
|
2288
2993
|
}));
|
|
2289
2994
|
|
|
2995
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
2996
|
+
|
|
2290
2997
|
await expect(sync).resolves.toEqual({
|
|
2291
|
-
status: "
|
|
2292
|
-
reason: "
|
|
2998
|
+
status: "skipped",
|
|
2999
|
+
reason: "superseded",
|
|
2293
3000
|
});
|
|
2294
3001
|
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
2295
3002
|
expect(sessionStorage.getItem("drip_cart_attr")).toBeNull();
|
|
@@ -2326,7 +3033,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2326
3033
|
})).resolves.toEqual({ status: "sent", reason: "already_stamped" });
|
|
2327
3034
|
|
|
2328
3035
|
resolveOlder(new Response("ok", { status: 200 }));
|
|
2329
|
-
await expect(older).resolves.toEqual({ status: "
|
|
3036
|
+
await expect(older).resolves.toEqual({ status: "skipped", reason: "superseded" });
|
|
2330
3037
|
expect(sessionStorage.getItem("drip_cart_attr")).toBe(newestStamp);
|
|
2331
3038
|
});
|
|
2332
3039
|
|
|
@@ -2344,6 +3051,8 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2344
3051
|
assignment_epoch: 2,
|
|
2345
3052
|
}],
|
|
2346
3053
|
commerce_authorization: "signed-envelope",
|
|
3054
|
+
handoff_persisted: false,
|
|
3055
|
+
handoff_stale_risk: false,
|
|
2347
3056
|
}))
|
|
2348
3057
|
.mockResolvedValueOnce(new Response("ok", { status: 200 })) as any;
|
|
2349
3058
|
const { syncShopifyCartAttributes } = await import("./shopify-cart");
|
|
@@ -2770,6 +3479,7 @@ describe("syncShopifyCartAttributes", () => {
|
|
|
2770
3479
|
|
|
2771
3480
|
const proofBody = JSON.parse(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[1]?.body));
|
|
2772
3481
|
expect(proofBody.cart_token).toBe("gid://shopify/Cart/headless?key=headless-secret");
|
|
3482
|
+
expect(proofBody.persist_function_handoff).toBe(true);
|
|
2773
3483
|
const cartUpdateBody = JSON.parse(
|
|
2774
3484
|
String(vi.mocked(globalThis.fetch).mock.calls[1]?.[1]?.body),
|
|
2775
3485
|
);
|