@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,999 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { UrlRule } from "./types";
|
|
3
|
+
import {
|
|
4
|
+
RECENT_MONDAY_NOON, documentListeners, windowListeners, appendedAntiFlickerStyle, configPayloadHash, flushDynamicImports, installCookieJar, installMutationObserverStub, makeClassList, makeStorage, readCookieValue, readDecodedDripContext, registerInitTestLifecycle, verifiedHandoverResponse,
|
|
5
|
+
} from "./init-test-helpers";
|
|
6
|
+
|
|
7
|
+
describe("init config resolution", () => {
|
|
8
|
+
registerInitTestLifecycle();
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
it("discards a matching bootstrap predecision for a global-holdout visitor", async () => {
|
|
12
|
+
const { isHoldoutVisitor } = await import("./hash");
|
|
13
|
+
const visitorId = Array.from({ length: 2000 }, (_, index) => `predecision-holdout-${index}`)
|
|
14
|
+
.find((id) => isHoldoutVisitor({ holdout_v1_enabled: true, holdout_pct: 10 }, id))!;
|
|
15
|
+
(globalThis as any).__dripPredecided = {
|
|
16
|
+
decision: [["exp_holdout_predecision", "var_treatment"]], configRevision: 7, visitorId, groups: {},
|
|
17
|
+
};
|
|
18
|
+
const { init } = await import("./index");
|
|
19
|
+
const assignments = init({
|
|
20
|
+
antiFlickerMode: "off", userId: visitorId, delivery: { revision: 7 },
|
|
21
|
+
holdout_v1_enabled: true, holdout_pct: 10,
|
|
22
|
+
experiments: [{ id: "exp_holdout_predecision", name: "Holdout", status: "running", trafficAllocation: 1,
|
|
23
|
+
variations: [{ id: "var_treatment", weight: 1, mutations: [] }] }],
|
|
24
|
+
});
|
|
25
|
+
expect(assignments).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
it("keeps a predecided sticky winner after traffic allocation is reduced", async () => {
|
|
30
|
+
const visitorId = "predecision-sticky-visitor";
|
|
31
|
+
(globalThis as any).__dripPredecided = {
|
|
32
|
+
decision: [["exp_sticky_predecision", "var_sticky"]], configRevision: 8, visitorId, groups: {},
|
|
33
|
+
};
|
|
34
|
+
const { init } = await import("./index");
|
|
35
|
+
const assignments = init({
|
|
36
|
+
antiFlickerMode: "off", userId: visitorId, delivery: { revision: 8 },
|
|
37
|
+
experiments: [{ id: "exp_sticky_predecision", name: "Sticky", status: "running", trafficAllocation: 0,
|
|
38
|
+
variations: [{ id: "var_sticky", weight: 1, mutations: [] }] }],
|
|
39
|
+
});
|
|
40
|
+
expect(assignments).toEqual([expect.objectContaining({ experimentId: "exp_sticky_predecision", variationId: "var_sticky" })]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
it("applies the matching URL block on a predecided first visit", async () => {
|
|
45
|
+
const visitorId = "predecision-url-block-visitor";
|
|
46
|
+
const title = { textContent: "Original" };
|
|
47
|
+
installMutationObserverStub();
|
|
48
|
+
(globalThis as any).location = {
|
|
49
|
+
hash: "",
|
|
50
|
+
hostname: "plain.test",
|
|
51
|
+
href: "https://plain.test/products/a",
|
|
52
|
+
origin: "https://plain.test",
|
|
53
|
+
pathname: "/products/a",
|
|
54
|
+
search: "",
|
|
55
|
+
};
|
|
56
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
57
|
+
selector === "#product-title" ? [title] : []
|
|
58
|
+
));
|
|
59
|
+
(globalThis as any).__dripPredecided = {
|
|
60
|
+
decision: [["exp_url_block_predecision", "var_treatment"]],
|
|
61
|
+
configRevision: 1097,
|
|
62
|
+
visitorId,
|
|
63
|
+
groups: {},
|
|
64
|
+
};
|
|
65
|
+
const { init } = await import("./index");
|
|
66
|
+
|
|
67
|
+
const assignments = init({
|
|
68
|
+
antiFlickerMode: "off",
|
|
69
|
+
delivery: { revision: 1097 },
|
|
70
|
+
experiments: [{
|
|
71
|
+
id: "exp_url_block_predecision",
|
|
72
|
+
name: "URL block predecision",
|
|
73
|
+
status: "running",
|
|
74
|
+
trafficAllocation: 0,
|
|
75
|
+
variations: [{
|
|
76
|
+
id: "var_treatment",
|
|
77
|
+
weight: 1,
|
|
78
|
+
mutations: [{
|
|
79
|
+
selector: "#product-title",
|
|
80
|
+
action: "text",
|
|
81
|
+
text: "Flat fallback",
|
|
82
|
+
}],
|
|
83
|
+
urlBlocks: [{
|
|
84
|
+
id: "product-a",
|
|
85
|
+
name: "Product A",
|
|
86
|
+
urlRules: [{ type: "simple", pattern: "/products/a" }],
|
|
87
|
+
mutations: [{
|
|
88
|
+
selector: "#product-title",
|
|
89
|
+
action: "text",
|
|
90
|
+
text: "Block treatment",
|
|
91
|
+
}],
|
|
92
|
+
}],
|
|
93
|
+
}],
|
|
94
|
+
}],
|
|
95
|
+
userId: visitorId,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(assignments[0]).toMatchObject({
|
|
99
|
+
activeUrlBlockId: "product-a",
|
|
100
|
+
variationId: "var_treatment",
|
|
101
|
+
});
|
|
102
|
+
expect(title.textContent).toBe("Block treatment");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
it.each([
|
|
107
|
+
[
|
|
108
|
+
"QA force link",
|
|
109
|
+
"?drip_qa=1&drip_experiment_id=exp_qa_predecision&drip_variation_id=var_qa_treatment",
|
|
110
|
+
"",
|
|
111
|
+
],
|
|
112
|
+
[
|
|
113
|
+
"QA force cookie",
|
|
114
|
+
"",
|
|
115
|
+
"drip_qa_force=exp_qa_predecision%3Avar_qa_treatment",
|
|
116
|
+
],
|
|
117
|
+
])("defers a %s override of an early_reassert predecision until hydration", async (
|
|
118
|
+
_source,
|
|
119
|
+
search,
|
|
120
|
+
cookie,
|
|
121
|
+
) => {
|
|
122
|
+
const visitorId = "qa-predecision-visitor";
|
|
123
|
+
const forcedTitle = { textContent: "Original forced title" };
|
|
124
|
+
const siblingTitle = { textContent: "Original sibling title" };
|
|
125
|
+
installMutationObserverStub();
|
|
126
|
+
const earlyReassert = await import("./early-reassert");
|
|
127
|
+
const installEarlyReassert = vi.spyOn(earlyReassert, "installEarlyReassert");
|
|
128
|
+
Object.assign((globalThis as any).location, {
|
|
129
|
+
href: `https://plain.test/${search}`,
|
|
130
|
+
search,
|
|
131
|
+
});
|
|
132
|
+
(globalThis as any).document.cookie = cookie;
|
|
133
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => {
|
|
134
|
+
if (selector === "#forced-title") return [forcedTitle];
|
|
135
|
+
if (selector === "#sibling-title") return [siblingTitle];
|
|
136
|
+
return [];
|
|
137
|
+
});
|
|
138
|
+
(globalThis as any).__dripPredecided = {
|
|
139
|
+
decision: [
|
|
140
|
+
["exp_qa_predecision", "var_qa_control"],
|
|
141
|
+
["exp_qa_sibling", "var_sibling_treatment"],
|
|
142
|
+
],
|
|
143
|
+
configRevision: 1215,
|
|
144
|
+
visitorId,
|
|
145
|
+
groups: {},
|
|
146
|
+
};
|
|
147
|
+
const { getAssignments, hydrated, init } = await import("./index");
|
|
148
|
+
|
|
149
|
+
init({
|
|
150
|
+
antiFlickerMode: "off",
|
|
151
|
+
delivery: { revision: 1215 },
|
|
152
|
+
experiments: [
|
|
153
|
+
{
|
|
154
|
+
id: "exp_qa_predecision",
|
|
155
|
+
status: "running",
|
|
156
|
+
trafficAllocation: 1,
|
|
157
|
+
variations: [
|
|
158
|
+
{ id: "var_qa_control", isControl: true, weight: 1, mutations: [] },
|
|
159
|
+
{
|
|
160
|
+
id: "var_qa_treatment",
|
|
161
|
+
weight: 0,
|
|
162
|
+
mutations: [{ selector: "#forced-title", action: "text", text: "Forced treatment" }],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "exp_qa_sibling",
|
|
168
|
+
status: "running",
|
|
169
|
+
trafficAllocation: 1,
|
|
170
|
+
variations: [
|
|
171
|
+
{ id: "var_sibling_control", isControl: true, weight: 1, mutations: [] },
|
|
172
|
+
{
|
|
173
|
+
id: "var_sibling_treatment",
|
|
174
|
+
weight: 0,
|
|
175
|
+
mutations: [{ selector: "#sibling-title", action: "text", text: "Sibling predecision" }],
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
shopId: "shop_123",
|
|
181
|
+
spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 },
|
|
182
|
+
trackingStart: "immediate",
|
|
183
|
+
userId: visitorId,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(getAssignments()).toEqual([
|
|
187
|
+
expect.objectContaining({
|
|
188
|
+
experimentId: "exp_qa_predecision",
|
|
189
|
+
variationId: "var_qa_treatment",
|
|
190
|
+
}),
|
|
191
|
+
expect.objectContaining({
|
|
192
|
+
experimentId: "exp_qa_sibling",
|
|
193
|
+
variationId: "var_sibling_treatment",
|
|
194
|
+
}),
|
|
195
|
+
]);
|
|
196
|
+
expect(forcedTitle.textContent).toBe("Original forced title");
|
|
197
|
+
expect(siblingTitle.textContent).toBe("Original sibling title");
|
|
198
|
+
expect(installEarlyReassert).not.toHaveBeenCalled();
|
|
199
|
+
|
|
200
|
+
hydrated();
|
|
201
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
202
|
+
|
|
203
|
+
expect(forcedTitle.textContent).toBe("Forced treatment");
|
|
204
|
+
expect(siblingTitle.textContent).toBe("Sibling predecision");
|
|
205
|
+
expect((globalThis as any).localStorage.getItem("drip_assignments_v1")).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
it("writes only the functional QA force cookie when consent is denied", async () => {
|
|
210
|
+
Object.assign((globalThis as any).location, {
|
|
211
|
+
href: "https://plain.test/?drip_qa=1&drip_force=exp_consent_qa%3Avar_consent_qa",
|
|
212
|
+
search: "?drip_qa=1&drip_force=exp_consent_qa%3Avar_consent_qa",
|
|
213
|
+
});
|
|
214
|
+
const cookieWrites = installCookieJar();
|
|
215
|
+
const { getDiagnostics, init } = await import("./index");
|
|
216
|
+
|
|
217
|
+
const assignments = init({
|
|
218
|
+
antiFlickerMode: "off",
|
|
219
|
+
consentMode: { enabled: true },
|
|
220
|
+
experiments: [{
|
|
221
|
+
id: "exp_consent_qa",
|
|
222
|
+
status: "draft",
|
|
223
|
+
trafficAllocation: 1,
|
|
224
|
+
variations: [
|
|
225
|
+
{ id: "var_control", isControl: true, weight: 1, mutations: [] },
|
|
226
|
+
{ id: "var_consent_qa", weight: 0, mutations: [] },
|
|
227
|
+
],
|
|
228
|
+
}],
|
|
229
|
+
hasConsent: false,
|
|
230
|
+
shopId: "shop_123",
|
|
231
|
+
trackingStart: "immediate",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
expect(getDiagnostics().qaMode).toBe(true);
|
|
235
|
+
expect(assignments).toEqual([
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
experimentId: "exp_consent_qa",
|
|
238
|
+
variationId: "var_consent_qa",
|
|
239
|
+
}),
|
|
240
|
+
]);
|
|
241
|
+
expect(cookieWrites).toContain(
|
|
242
|
+
"drip_qa_force=exp_consent_qa%3Avar_consent_qa;path=/;max-age=1800;samesite=lax",
|
|
243
|
+
);
|
|
244
|
+
expect((globalThis as any).localStorage.getItem("drip_uid")).toBeNull();
|
|
245
|
+
expect((globalThis as any).localStorage.getItem("drip_assignments_v1")).toBeNull();
|
|
246
|
+
expect(readCookieValue("drip_uid")).toBeNull();
|
|
247
|
+
expect(readCookieValue("drip_assignments_v1")).toBeNull();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
it("restores and renews a cookie-only QA force when consent is denied", async () => {
|
|
252
|
+
const cookieWrites = installCookieJar(
|
|
253
|
+
"drip_qa_force=exp_cookie_qa%3Avar_cookie_qa",
|
|
254
|
+
);
|
|
255
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
|
|
256
|
+
String(input).includes("/config")
|
|
257
|
+
? Response.json({
|
|
258
|
+
experiments: [{
|
|
259
|
+
id: "exp_cookie_qa",
|
|
260
|
+
status: "running",
|
|
261
|
+
trafficAllocation: 1,
|
|
262
|
+
variations: [
|
|
263
|
+
{ id: "var_control", isControl: true, weight: 1, mutations: [] },
|
|
264
|
+
{ id: "var_cookie_qa", weight: 0, mutations: [] },
|
|
265
|
+
],
|
|
266
|
+
}],
|
|
267
|
+
})
|
|
268
|
+
: new Response("ok", { status: 202 })
|
|
269
|
+
)) as any;
|
|
270
|
+
const { getAssignments, getDiagnostics, init } = await import("./index");
|
|
271
|
+
|
|
272
|
+
init({
|
|
273
|
+
antiFlickerMode: "off",
|
|
274
|
+
consentMode: { enabled: true },
|
|
275
|
+
endpoint: "https://events.test",
|
|
276
|
+
hasConsent: false,
|
|
277
|
+
shopId: "shop_123",
|
|
278
|
+
trackingStart: "immediate",
|
|
279
|
+
});
|
|
280
|
+
await vi.waitFor(() => expect(getDiagnostics().configSource).toBe("network"));
|
|
281
|
+
|
|
282
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
283
|
+
"https://events.test/config?shopId=shop_123&drip_qa=1&drip_force=exp_cookie_qa%3Avar_cookie_qa",
|
|
284
|
+
expect.objectContaining({ signal: expect.any(Object) }),
|
|
285
|
+
);
|
|
286
|
+
expect(getDiagnostics().qaMode).toBe(true);
|
|
287
|
+
expect(getAssignments()).toEqual([
|
|
288
|
+
expect.objectContaining({
|
|
289
|
+
experimentId: "exp_cookie_qa",
|
|
290
|
+
variationId: "var_cookie_qa",
|
|
291
|
+
}),
|
|
292
|
+
]);
|
|
293
|
+
expect(cookieWrites).toContain(
|
|
294
|
+
"drip_qa_force=exp_cookie_qa%3Avar_cookie_qa;path=/;max-age=1800;samesite=lax",
|
|
295
|
+
);
|
|
296
|
+
expect((globalThis as any).localStorage.getItem("drip_uid")).toBeNull();
|
|
297
|
+
expect((globalThis as any).localStorage.getItem("drip_assignments_v1")).toBeNull();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
it("defers custom-JS early_reassert assignments until hydration", async () => {
|
|
302
|
+
const visitorId = "custom-js-predecision-visitor";
|
|
303
|
+
installMutationObserverStub();
|
|
304
|
+
const target = {
|
|
305
|
+
getAttribute: vi.fn(() => null),
|
|
306
|
+
removeAttribute: vi.fn(),
|
|
307
|
+
setAttribute: vi.fn(),
|
|
308
|
+
};
|
|
309
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
310
|
+
selector === "#custom-js-target" ? [target] : []
|
|
311
|
+
));
|
|
312
|
+
(globalThis as any).__dripPredecided = {
|
|
313
|
+
decision: [["exp_custom_js_predecision", "var_custom_js_treatment"]],
|
|
314
|
+
configRevision: 1217,
|
|
315
|
+
visitorId,
|
|
316
|
+
groups: {},
|
|
317
|
+
};
|
|
318
|
+
const { hydrated, init } = await import("./index");
|
|
319
|
+
|
|
320
|
+
init({
|
|
321
|
+
antiFlickerMode: "off",
|
|
322
|
+
delivery: { revision: 1217 },
|
|
323
|
+
experiments: [{
|
|
324
|
+
id: "exp_custom_js_predecision",
|
|
325
|
+
status: "running",
|
|
326
|
+
trafficAllocation: 1,
|
|
327
|
+
variations: [{
|
|
328
|
+
id: "var_custom_js_treatment",
|
|
329
|
+
weight: 1,
|
|
330
|
+
mutations: [{
|
|
331
|
+
selector: "#custom-js-target",
|
|
332
|
+
action: "attribute",
|
|
333
|
+
attribute: { name: "data-treatment", value: "active" },
|
|
334
|
+
}],
|
|
335
|
+
js: "document.title='x'",
|
|
336
|
+
}],
|
|
337
|
+
}],
|
|
338
|
+
shopId: "shop_123",
|
|
339
|
+
spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 },
|
|
340
|
+
trackingStart: "immediate",
|
|
341
|
+
userId: visitorId,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(target.setAttribute).not.toHaveBeenCalled();
|
|
345
|
+
expect((globalThis as any).document.title).toBe("Plain probe");
|
|
346
|
+
|
|
347
|
+
hydrated();
|
|
348
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
349
|
+
|
|
350
|
+
expect(target.setAttribute).toHaveBeenCalledWith("data-treatment", "active");
|
|
351
|
+
expect((globalThis as any).document.title).toBe("x");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
it("defers a cookie-forced network QA treatment until hydration under early_reassert", async () => {
|
|
356
|
+
const title = { textContent: "Original title" };
|
|
357
|
+
installMutationObserverStub();
|
|
358
|
+
Object.defineProperty((globalThis as any).document, "cookie", {
|
|
359
|
+
configurable: true,
|
|
360
|
+
get: () => "drip_qa_force=exp_qa_network%3Avar_qa_network_treatment",
|
|
361
|
+
set: vi.fn(),
|
|
362
|
+
});
|
|
363
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
364
|
+
selector === "#qa-network-title" ? [title] : []
|
|
365
|
+
));
|
|
366
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
|
|
367
|
+
String(input).includes("/config")
|
|
368
|
+
? new Response(JSON.stringify({
|
|
369
|
+
experiments: [{
|
|
370
|
+
id: "exp_qa_network",
|
|
371
|
+
status: "running",
|
|
372
|
+
trafficAllocation: 1,
|
|
373
|
+
variations: [
|
|
374
|
+
{ id: "var_qa_network_control", isControl: true, weight: 1, mutations: [] },
|
|
375
|
+
{
|
|
376
|
+
id: "var_qa_network_treatment",
|
|
377
|
+
weight: 0,
|
|
378
|
+
mutations: [{ selector: "#qa-network-title", action: "text", text: "Forced treatment" }],
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
}],
|
|
382
|
+
runtime: { spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 } },
|
|
383
|
+
}), { status: 200 })
|
|
384
|
+
: new Response("ok", { status: 202 })
|
|
385
|
+
)) as any;
|
|
386
|
+
const { getAssignments, getDiagnostics, hydrated, init } = await import("./index");
|
|
387
|
+
|
|
388
|
+
init({
|
|
389
|
+
antiFlickerMode: "off",
|
|
390
|
+
endpoint: "https://events.test",
|
|
391
|
+
shopId: "shop_123",
|
|
392
|
+
trackingStart: "immediate",
|
|
393
|
+
});
|
|
394
|
+
await vi.waitFor(() => expect(getDiagnostics().configSource).toBe("network"));
|
|
395
|
+
|
|
396
|
+
expect(getDiagnostics().qaMode).toBe(true);
|
|
397
|
+
expect(getAssignments()).toEqual([
|
|
398
|
+
expect.objectContaining({
|
|
399
|
+
experimentId: "exp_qa_network",
|
|
400
|
+
variationId: "var_qa_network_treatment",
|
|
401
|
+
}),
|
|
402
|
+
]);
|
|
403
|
+
expect(title.textContent).toBe("Original title");
|
|
404
|
+
|
|
405
|
+
hydrated();
|
|
406
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
407
|
+
|
|
408
|
+
expect(title.textContent).toBe("Forced treatment");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
it("defers a screenshot-forced network treatment until hydration under early_reassert", async () => {
|
|
413
|
+
const title = { textContent: "Original title" };
|
|
414
|
+
installMutationObserverStub();
|
|
415
|
+
Object.assign((globalThis as any).location, {
|
|
416
|
+
href: "https://plain.test/?drip_screenshot=1&drip_force=exp_ss_network%3Avar_ss_network_treatment",
|
|
417
|
+
search: "?drip_screenshot=1&drip_force=exp_ss_network%3Avar_ss_network_treatment",
|
|
418
|
+
});
|
|
419
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
420
|
+
selector === "#ss-network-title" ? [title] : []
|
|
421
|
+
));
|
|
422
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
|
|
423
|
+
String(input).includes("/config")
|
|
424
|
+
? new Response(JSON.stringify({
|
|
425
|
+
experiments: [{
|
|
426
|
+
id: "exp_ss_network",
|
|
427
|
+
status: "running",
|
|
428
|
+
trafficAllocation: 1,
|
|
429
|
+
variations: [
|
|
430
|
+
{ id: "var_ss_network_control", isControl: true, weight: 1, mutations: [] },
|
|
431
|
+
{
|
|
432
|
+
id: "var_ss_network_treatment",
|
|
433
|
+
weight: 0,
|
|
434
|
+
mutations: [{ selector: "#ss-network-title", action: "text", text: "Forced treatment" }],
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
}],
|
|
438
|
+
runtime: { spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 } },
|
|
439
|
+
}), { status: 200 })
|
|
440
|
+
: new Response("ok", { status: 202 })
|
|
441
|
+
)) as any;
|
|
442
|
+
const { getAssignments, getDiagnostics, hydrated, init } = await import("./index");
|
|
443
|
+
|
|
444
|
+
init({
|
|
445
|
+
antiFlickerMode: "off",
|
|
446
|
+
endpoint: "https://events.test",
|
|
447
|
+
shopId: "shop_123",
|
|
448
|
+
trackingStart: "immediate",
|
|
449
|
+
});
|
|
450
|
+
await vi.waitFor(() => expect(getDiagnostics().configSource).toBe("network"));
|
|
451
|
+
|
|
452
|
+
expect(getDiagnostics().qaMode).toBe(false);
|
|
453
|
+
expect(getAssignments()).toEqual([
|
|
454
|
+
expect.objectContaining({
|
|
455
|
+
experimentId: "exp_ss_network",
|
|
456
|
+
variationId: "var_ss_network_treatment",
|
|
457
|
+
}),
|
|
458
|
+
]);
|
|
459
|
+
expect(title.textContent).toBe("Original title");
|
|
460
|
+
|
|
461
|
+
hydrated();
|
|
462
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
463
|
+
|
|
464
|
+
expect(title.textContent).toBe("Forced treatment");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
it("keeps non-QA network early_reassert treatments immediate", async () => {
|
|
469
|
+
const title = { textContent: "Original title" };
|
|
470
|
+
installMutationObserverStub();
|
|
471
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
472
|
+
selector === "#network-title" ? [title] : []
|
|
473
|
+
));
|
|
474
|
+
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => (
|
|
475
|
+
String(input).includes("/config")
|
|
476
|
+
? new Response(JSON.stringify({
|
|
477
|
+
experiments: [{
|
|
478
|
+
id: "exp_network",
|
|
479
|
+
status: "running",
|
|
480
|
+
trafficAllocation: 1,
|
|
481
|
+
variations: [{
|
|
482
|
+
id: "var_network_treatment",
|
|
483
|
+
weight: 1,
|
|
484
|
+
mutations: [{ selector: "#network-title", action: "text", text: "Treatment" }],
|
|
485
|
+
}],
|
|
486
|
+
}],
|
|
487
|
+
runtime: { spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 } },
|
|
488
|
+
}), { status: 200 })
|
|
489
|
+
: new Response("ok", { status: 202 })
|
|
490
|
+
)) as any;
|
|
491
|
+
const { getDiagnostics, init } = await import("./index");
|
|
492
|
+
|
|
493
|
+
init({
|
|
494
|
+
antiFlickerMode: "off",
|
|
495
|
+
endpoint: "https://events.test",
|
|
496
|
+
shopId: "shop_123",
|
|
497
|
+
trackingStart: "immediate",
|
|
498
|
+
});
|
|
499
|
+
await vi.waitFor(() => expect(getDiagnostics().configSource).toBe("network"));
|
|
500
|
+
|
|
501
|
+
expect(title.textContent).toBe("Treatment");
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
it("keeps an early_reassert predecision when drip_force=clear removes a stale QA force", async () => {
|
|
506
|
+
const visitorId = "qa-predecision-clear-visitor";
|
|
507
|
+
const title = { textContent: "Original title" };
|
|
508
|
+
installMutationObserverStub();
|
|
509
|
+
Object.assign((globalThis as any).location, {
|
|
510
|
+
href: "https://plain.test/?drip_force=clear",
|
|
511
|
+
search: "?drip_force=clear",
|
|
512
|
+
});
|
|
513
|
+
(globalThis as any).document.cookie = "drip_qa_force=exp_qa_clear%3Avar_qa_treatment";
|
|
514
|
+
(globalThis as any).document.querySelectorAll = vi.fn((selector: string) => (
|
|
515
|
+
selector === "#clear-title" ? [title] : []
|
|
516
|
+
));
|
|
517
|
+
(globalThis as any).__dripPredecided = {
|
|
518
|
+
decision: [["exp_qa_clear", "var_qa_control"]],
|
|
519
|
+
configRevision: 1216,
|
|
520
|
+
visitorId,
|
|
521
|
+
groups: {},
|
|
522
|
+
};
|
|
523
|
+
const { getAssignments, init } = await import("./index");
|
|
524
|
+
|
|
525
|
+
init({
|
|
526
|
+
antiFlickerMode: "off",
|
|
527
|
+
delivery: { revision: 1216 },
|
|
528
|
+
experiments: [{
|
|
529
|
+
id: "exp_qa_clear",
|
|
530
|
+
status: "running",
|
|
531
|
+
trafficAllocation: 1,
|
|
532
|
+
variations: [
|
|
533
|
+
{
|
|
534
|
+
id: "var_qa_control",
|
|
535
|
+
isControl: true,
|
|
536
|
+
weight: 0,
|
|
537
|
+
mutations: [{ selector: "#clear-title", action: "text", text: "Predecided control" }],
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
id: "var_qa_treatment",
|
|
541
|
+
weight: 1,
|
|
542
|
+
mutations: [{ selector: "#clear-title", action: "text", text: "Stale forced treatment" }],
|
|
543
|
+
},
|
|
544
|
+
],
|
|
545
|
+
}],
|
|
546
|
+
shopId: "shop_123",
|
|
547
|
+
spa: { applyTiming: "early_reassert", hydrationTimeoutMs: 3_000 },
|
|
548
|
+
trackingStart: "immediate",
|
|
549
|
+
userId: visitorId,
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(getAssignments()).toEqual([
|
|
553
|
+
expect.objectContaining({
|
|
554
|
+
experimentId: "exp_qa_clear",
|
|
555
|
+
variationId: "var_qa_control",
|
|
556
|
+
}),
|
|
557
|
+
]);
|
|
558
|
+
expect(title.textContent).toBe("Predecided control");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
it("persists a bootstrap provisional visitor identity when storage is allowed", async () => {
|
|
563
|
+
const visitorId = "predecision-persisted-visitor";
|
|
564
|
+
(globalThis as any).__dripPredecided = {
|
|
565
|
+
decision: [], configRevision: 9, visitorId, groups: {},
|
|
566
|
+
};
|
|
567
|
+
const { init } = await import("./index");
|
|
568
|
+
const { getUserId } = await import("./hash");
|
|
569
|
+
|
|
570
|
+
init({
|
|
571
|
+
antiFlickerMode: "off",
|
|
572
|
+
delivery: { revision: 9 },
|
|
573
|
+
experiments: [],
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
expect((globalThis as any).localStorage.getItem("drip_uid")).toBe(visitorId);
|
|
577
|
+
expect(getUserId()).toBe(visitorId);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
it("does not persist a client-provided identity when a bootstrap identity is present", async () => {
|
|
582
|
+
(globalThis as any).__dripPredecided = {
|
|
583
|
+
decision: [], configRevision: 10, visitorId: "predecision-ignored-visitor", groups: {},
|
|
584
|
+
};
|
|
585
|
+
const { init } = await import("./index");
|
|
586
|
+
|
|
587
|
+
init({
|
|
588
|
+
antiFlickerMode: "off",
|
|
589
|
+
delivery: { revision: 10 },
|
|
590
|
+
experiments: [],
|
|
591
|
+
userId: "client-provided-visitor",
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
expect((globalThis as any).localStorage.getItem("drip_uid")).toBeNull();
|
|
595
|
+
expect(readCookieValue("drip_uid")).toBeNull();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
it("does not persist a bootstrap provisional visitor identity when storage is blocked", async () => {
|
|
600
|
+
(globalThis as any).__dripPredecided = {
|
|
601
|
+
decision: [], configRevision: 11, visitorId: "predecision-ephemeral-visitor", groups: {},
|
|
602
|
+
};
|
|
603
|
+
const { init } = await import("./index");
|
|
604
|
+
|
|
605
|
+
init({
|
|
606
|
+
antiFlickerMode: "off",
|
|
607
|
+
consentMode: { enabled: true },
|
|
608
|
+
delivery: { revision: 11 },
|
|
609
|
+
experiments: [],
|
|
610
|
+
hasConsent: false,
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
expect((globalThis as any).localStorage.getItem("drip_uid")).toBeNull();
|
|
614
|
+
expect(readCookieValue("drip_uid")).toBeNull();
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
it("clears bootstrap predecision state when diagnostic evaluation throws", async () => {
|
|
619
|
+
const targeting = await import("./targeting");
|
|
620
|
+
const evaluateTargeting = vi.spyOn(targeting, "evaluateTargetingGatesDetailed")
|
|
621
|
+
.mockImplementationOnce(() => {
|
|
622
|
+
throw new Error("diagnostic evaluation failed");
|
|
623
|
+
});
|
|
624
|
+
const { evaluate, init } = await import("./index");
|
|
625
|
+
const visitorId = "predecision-throw-visitor";
|
|
626
|
+
const experiments = [{
|
|
627
|
+
id: "exp_predecision_throw",
|
|
628
|
+
name: "Throwing predecision",
|
|
629
|
+
status: "running" as const,
|
|
630
|
+
trafficAllocation: 0,
|
|
631
|
+
variations: [{ id: "var_predecision_throw", weight: 1, mutations: [] }],
|
|
632
|
+
}];
|
|
633
|
+
(globalThis as any).__dripPredecided = {
|
|
634
|
+
decision: [["exp_predecision_throw", "var_predecision_throw"]],
|
|
635
|
+
configRevision: 12,
|
|
636
|
+
visitorId,
|
|
637
|
+
groups: {},
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
expect(() => init({
|
|
641
|
+
antiFlickerMode: "off",
|
|
642
|
+
delivery: { revision: 12 },
|
|
643
|
+
experiments,
|
|
644
|
+
})).toThrow("diagnostic evaluation failed");
|
|
645
|
+
expect(evaluateTargeting).toHaveBeenCalled();
|
|
646
|
+
|
|
647
|
+
expect(evaluate(experiments, { userId: visitorId })).toEqual([]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
it("installs inline request geography before evaluating country targeting", async () => {
|
|
652
|
+
const { getSignals, init } = await import("./index");
|
|
653
|
+
const assignments = init({
|
|
654
|
+
antiFlickerMode: "off",
|
|
655
|
+
geo: { country: "DE" },
|
|
656
|
+
trackingStart: "immediate",
|
|
657
|
+
userId: "geo-inline-visitor",
|
|
658
|
+
experiments: [{
|
|
659
|
+
id: "exp_geo_inline",
|
|
660
|
+
name: "Germany",
|
|
661
|
+
status: "running",
|
|
662
|
+
trafficAllocation: 1,
|
|
663
|
+
targeting: {
|
|
664
|
+
segmentRules: [{
|
|
665
|
+
id: "seg_geo_inline",
|
|
666
|
+
include: true,
|
|
667
|
+
rules: {
|
|
668
|
+
version: 1,
|
|
669
|
+
operator: "and",
|
|
670
|
+
conditions: [{ signal: "geo.country", operator: "equals", value: "DE" }],
|
|
671
|
+
},
|
|
672
|
+
}],
|
|
673
|
+
},
|
|
674
|
+
variations: [{ id: "var_geo", weight: 1, mutations: [] }],
|
|
675
|
+
}],
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
expect(getSignals({ markVisitorSeen: false })["geo.country"]).toBe("DE");
|
|
679
|
+
expect(assignments).toEqual([expect.objectContaining({ experimentId: "exp_geo_inline" })]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
it("reuses targeting attributes within one evaluation pass and rebuilds them for re-init", async () => {
|
|
684
|
+
const targeting = await import("./targeting");
|
|
685
|
+
const buildTargetingAttributes = vi.spyOn(targeting, "buildTargetingAttributes");
|
|
686
|
+
const { init, reInit } = await import("./index");
|
|
687
|
+
const experiments = [{
|
|
688
|
+
id: "exp_targeting_pass",
|
|
689
|
+
status: "running" as const,
|
|
690
|
+
trafficAllocation: 1,
|
|
691
|
+
variations: [{ id: "var_targeting_pass", weight: 1, mutations: [] }],
|
|
692
|
+
}];
|
|
693
|
+
|
|
694
|
+
expect(init({
|
|
695
|
+
antiFlickerMode: "off",
|
|
696
|
+
experiments,
|
|
697
|
+
trackingStart: "immediate",
|
|
698
|
+
userId: "targeting-pass-visitor",
|
|
699
|
+
})).toHaveLength(1);
|
|
700
|
+
expect(buildTargetingAttributes).toHaveBeenCalledTimes(1);
|
|
701
|
+
|
|
702
|
+
expect(reInit()).toHaveLength(1);
|
|
703
|
+
expect(buildTargetingAttributes).toHaveBeenCalledTimes(2);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
it("keeps the assignment-time DOM targeting snapshot through idle exposure and goal wiring", async () => {
|
|
708
|
+
let runIdle: (() => void) | undefined;
|
|
709
|
+
(globalThis as any).requestIdleCallback = vi.fn((callback: () => void) => {
|
|
710
|
+
runIdle = callback;
|
|
711
|
+
return 42;
|
|
712
|
+
});
|
|
713
|
+
(globalThis as any).cancelIdleCallback = vi.fn();
|
|
714
|
+
(globalThis as any).document.referrer = "https://www.google.com/search?q=apex";
|
|
715
|
+
|
|
716
|
+
const exposureSignals = await import("./exposure-signals");
|
|
717
|
+
const targeting = await import("./targeting");
|
|
718
|
+
const buildExposureSignalContext = vi.spyOn(exposureSignals, "buildExposureSignalContext");
|
|
719
|
+
const buildTargetingAttributes = vi.spyOn(targeting, "buildTargetingAttributes");
|
|
720
|
+
const evaluateTargeting = vi.spyOn(targeting, "evaluateTargetingGatesDetailed");
|
|
721
|
+
const { flush, init } = await import("./index");
|
|
722
|
+
const pageRule = {
|
|
723
|
+
id: "page_idle_assignment_snapshot",
|
|
724
|
+
includeRules: [{ type: "simple" as const, pattern: "/" }],
|
|
725
|
+
advancedCondition: {
|
|
726
|
+
code: 'return attributes["traffic.source"] === "search";',
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
expect(init({
|
|
731
|
+
antiFlickerMode: "off",
|
|
732
|
+
endpoint: "https://events.test",
|
|
733
|
+
experiments: [{
|
|
734
|
+
id: "exp_idle_assignment_snapshot",
|
|
735
|
+
status: "running",
|
|
736
|
+
targeting: { pageRules: [pageRule] },
|
|
737
|
+
trafficAllocation: 1,
|
|
738
|
+
variations: [{ id: "var_idle_assignment_snapshot", weight: 1, mutations: [] }],
|
|
739
|
+
goals: [{
|
|
740
|
+
id: "goal_idle_assignment_snapshot",
|
|
741
|
+
type: "click",
|
|
742
|
+
selector: ".buy",
|
|
743
|
+
pageId: pageRule.id,
|
|
744
|
+
}],
|
|
745
|
+
}],
|
|
746
|
+
shopId: "shop_123",
|
|
747
|
+
trackingStart: "idle",
|
|
748
|
+
userId: "visitor_idle_assignment_snapshot",
|
|
749
|
+
})).toHaveLength(1);
|
|
750
|
+
|
|
751
|
+
const decisionAttributes = evaluateTargeting.mock.calls[0]?.[1].attributes;
|
|
752
|
+
expect(decisionAttributes).toMatchObject({
|
|
753
|
+
referrer: "www.google.com",
|
|
754
|
+
"traffic.source": "search",
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
(globalThis as any).document.referrer = "https://mutated.test/from-dom";
|
|
758
|
+
runIdle?.();
|
|
759
|
+
|
|
760
|
+
const exposureAttributes = buildExposureSignalContext.mock.calls[0]?.[0].attributes;
|
|
761
|
+
expect(buildTargetingAttributes).toHaveBeenCalledTimes(1);
|
|
762
|
+
expect(exposureAttributes).toBe(decisionAttributes);
|
|
763
|
+
|
|
764
|
+
const target = {
|
|
765
|
+
closest: vi.fn((selector: string) => selector === ".buy" ? target : null),
|
|
766
|
+
textContent: "Buy",
|
|
767
|
+
};
|
|
768
|
+
for (const handler of documentListeners.click ?? []) handler({ target });
|
|
769
|
+
await flush();
|
|
770
|
+
|
|
771
|
+
const events = vi.mocked(globalThis.fetch).mock.calls
|
|
772
|
+
.filter(([url]) => url === "https://events.test/v1/events")
|
|
773
|
+
.flatMap(([, request]) => JSON.parse(String(request?.body)).events);
|
|
774
|
+
expect(events).toEqual(expect.arrayContaining([
|
|
775
|
+
expect.objectContaining({
|
|
776
|
+
type: "experiment_viewed",
|
|
777
|
+
data: expect.objectContaining({ signals: expect.objectContaining({ traffic_source: "search" }) }),
|
|
778
|
+
}),
|
|
779
|
+
expect.objectContaining({
|
|
780
|
+
type: "goal",
|
|
781
|
+
data: expect.objectContaining({ goalId: "goal_idle_assignment_snapshot" }),
|
|
782
|
+
}),
|
|
783
|
+
]));
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
it("rebuilds targeting attributes when navigation occurs before idle analytics starts", async () => {
|
|
788
|
+
let runIdle: (() => void) | undefined;
|
|
789
|
+
(globalThis as any).requestIdleCallback = vi.fn((callback: () => void) => {
|
|
790
|
+
runIdle = callback;
|
|
791
|
+
return 42;
|
|
792
|
+
});
|
|
793
|
+
(globalThis as any).cancelIdleCallback = vi.fn();
|
|
794
|
+
(globalThis as any).document.referrer = "https://www.google.com/search?q=apex";
|
|
795
|
+
|
|
796
|
+
const exposureSignals = await import("./exposure-signals");
|
|
797
|
+
const targeting = await import("./targeting");
|
|
798
|
+
const buildExposureSignalContext = vi.spyOn(exposureSignals, "buildExposureSignalContext");
|
|
799
|
+
const buildTargetingAttributes = vi.spyOn(targeting, "buildTargetingAttributes");
|
|
800
|
+
const evaluateTargeting = vi.spyOn(targeting, "evaluateTargetingGatesDetailed");
|
|
801
|
+
const { flush, init, reInit } = await import("./index");
|
|
802
|
+
|
|
803
|
+
expect(init({
|
|
804
|
+
antiFlickerMode: "off",
|
|
805
|
+
endpoint: "https://events.test",
|
|
806
|
+
experiments: [{
|
|
807
|
+
id: "exp_idle_navigation_fallback",
|
|
808
|
+
status: "running",
|
|
809
|
+
targeting: {
|
|
810
|
+
pageRules: [
|
|
811
|
+
{
|
|
812
|
+
id: "page_idle_assignment_url",
|
|
813
|
+
includeRules: [{ type: "simple", pattern: "/" }],
|
|
814
|
+
advancedCondition: {
|
|
815
|
+
code: 'return url.pathname === "/" && attributes["traffic.source"] === "search";',
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
id: "page_idle_navigation_url",
|
|
820
|
+
includeRules: [{ type: "simple", pattern: "/next" }],
|
|
821
|
+
advancedCondition: {
|
|
822
|
+
code: 'return attributes["traffic.source"] === "referral";',
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
],
|
|
826
|
+
},
|
|
827
|
+
trafficAllocation: 1,
|
|
828
|
+
variations: [{ id: "var_idle_navigation_fallback", weight: 1, mutations: [] }],
|
|
829
|
+
goals: [{
|
|
830
|
+
id: "goal_idle_navigation_fallback",
|
|
831
|
+
type: "click",
|
|
832
|
+
selector: ".buy",
|
|
833
|
+
pageId: "page_idle_navigation_url",
|
|
834
|
+
}],
|
|
835
|
+
}],
|
|
836
|
+
shopId: "shop_123",
|
|
837
|
+
trackingStart: "idle",
|
|
838
|
+
userId: "visitor_idle_navigation_fallback",
|
|
839
|
+
})).toHaveLength(1);
|
|
840
|
+
|
|
841
|
+
const decisionAttributes = evaluateTargeting.mock.calls[0]?.[1].attributes;
|
|
842
|
+
expect(decisionAttributes).toMatchObject({
|
|
843
|
+
"traffic.source": "search",
|
|
844
|
+
"url.path": "/",
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
Object.assign((globalThis as any).location, {
|
|
848
|
+
href: "https://plain.test/next",
|
|
849
|
+
pathname: "/next",
|
|
850
|
+
});
|
|
851
|
+
(globalThis as any).document.referrer = "https://mutated.test/from-dom";
|
|
852
|
+
reInit();
|
|
853
|
+
runIdle?.();
|
|
854
|
+
|
|
855
|
+
const exposureAttributes = buildExposureSignalContext.mock.calls[0]?.[0].attributes;
|
|
856
|
+
expect(buildTargetingAttributes).toHaveBeenCalledTimes(3);
|
|
857
|
+
expect(exposureAttributes).not.toBe(decisionAttributes);
|
|
858
|
+
expect(exposureAttributes).toMatchObject({
|
|
859
|
+
"traffic.source": "referral",
|
|
860
|
+
"url.path": "/next",
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
const target = {
|
|
864
|
+
closest: vi.fn((selector: string) => selector === ".buy" ? target : null),
|
|
865
|
+
textContent: "Buy",
|
|
866
|
+
};
|
|
867
|
+
for (const handler of documentListeners.click ?? []) handler({ target });
|
|
868
|
+
await flush();
|
|
869
|
+
|
|
870
|
+
const events = vi.mocked(globalThis.fetch).mock.calls
|
|
871
|
+
.filter(([url]) => url === "https://events.test/v1/events")
|
|
872
|
+
.flatMap(([, request]) => JSON.parse(String(request?.body)).events);
|
|
873
|
+
expect(events).toEqual(expect.arrayContaining([
|
|
874
|
+
expect.objectContaining({
|
|
875
|
+
type: "experiment_viewed",
|
|
876
|
+
data: expect.objectContaining({ signals: expect.objectContaining({ traffic_source: "referral" }) }),
|
|
877
|
+
}),
|
|
878
|
+
expect.objectContaining({
|
|
879
|
+
type: "goal",
|
|
880
|
+
data: expect.objectContaining({ goalId: "goal_idle_navigation_fallback" }),
|
|
881
|
+
}),
|
|
882
|
+
]));
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
it("does not run the audience-only page recheck without a persisted assignment", async () => {
|
|
887
|
+
const targeting = await import("./targeting");
|
|
888
|
+
const evaluateTargeting = vi.spyOn(targeting, "evaluateTargetingGatesDetailed");
|
|
889
|
+
const { evaluate } = await import("./index");
|
|
890
|
+
|
|
891
|
+
expect(evaluate([{
|
|
892
|
+
id: "exp_unpersisted_page_mismatch",
|
|
893
|
+
status: "running",
|
|
894
|
+
trafficAllocation: 1,
|
|
895
|
+
// Legacy bare-string URL rules stay accepted at runtime
|
|
896
|
+
// (normalizeRuntimeUrlRule); only the object form is in the declared type.
|
|
897
|
+
targeting: { url: ["https://plain.test/products/target"] as unknown as UrlRule[] },
|
|
898
|
+
variations: [{ id: "var_unpersisted_page_mismatch", weight: 1, mutations: [] }],
|
|
899
|
+
}], {
|
|
900
|
+
persistSignals: false,
|
|
901
|
+
url: "https://plain.test/products/other",
|
|
902
|
+
userId: "unpersisted-page-mismatch-visitor",
|
|
903
|
+
})).toEqual([]);
|
|
904
|
+
expect(evaluateTargeting).toHaveBeenCalledTimes(1);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
it("merges hydration apply timing from runtime settings", async () => {
|
|
909
|
+
const { __test } = await import("./index");
|
|
910
|
+
const config = __test!.withRuntimeSettings(
|
|
911
|
+
{ spa: { routerMode: "history", applyTiming: "immediate" } },
|
|
912
|
+
{ spa: { applyTiming: "after_hydration", hydrationTimeoutMs: 4_500 } },
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
expect(config.spa).toMatchObject({
|
|
916
|
+
routerMode: "history",
|
|
917
|
+
applyTiming: "after_hydration",
|
|
918
|
+
hydrationTimeoutMs: 4_500,
|
|
919
|
+
});
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
it("restores live assignments after the debug script fails", async () => {
|
|
924
|
+
(globalThis as any).location.search = "?drip_debug=1";
|
|
925
|
+
(globalThis as any).location.href = "https://plain.test/?drip_debug=1";
|
|
926
|
+
vi.mocked((globalThis as any).document.head.appendChild).mockImplementation((element: any) => element);
|
|
927
|
+
const { getAssignments, init } = await import("./index");
|
|
928
|
+
|
|
929
|
+
const result = init({
|
|
930
|
+
antiFlickerMode: "off",
|
|
931
|
+
debug: { enabled: true, restrictMode: "query_param" },
|
|
932
|
+
experiments: [{
|
|
933
|
+
id: "exp_debug_fallback",
|
|
934
|
+
name: "Debug fallback",
|
|
935
|
+
status: "running",
|
|
936
|
+
trafficAllocation: 1,
|
|
937
|
+
variations: [{ id: "var_live", weight: 1, mutations: [] }],
|
|
938
|
+
}],
|
|
939
|
+
runtimeFeatures: { sdkVariant: "live" },
|
|
940
|
+
snippetUrl: "https://events.example.test/s/shop.js",
|
|
941
|
+
userId: "debug-fallback-visitor",
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
expect(result).toEqual([]);
|
|
945
|
+
expect(getAssignments()).toEqual([]);
|
|
946
|
+
const debugScript = vi.mocked((globalThis as any).document.head.appendChild).mock.calls[0]?.[0] as {
|
|
947
|
+
onerror?: () => void;
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
debugScript.onerror?.();
|
|
951
|
+
|
|
952
|
+
expect(getAssignments()).toEqual([
|
|
953
|
+
expect.objectContaining({ experimentId: "exp_debug_fallback", variationId: "var_live" }),
|
|
954
|
+
]);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
it("reads only the logged-in segment construction from aggregate mode", async () => {
|
|
959
|
+
const { __test } = await import("./index");
|
|
960
|
+
const enabled = __test!.withRuntimeSettings(
|
|
961
|
+
{},
|
|
962
|
+
{ aggregateMode: { enabled: true, constructions: { loggedInSegment: true } } },
|
|
963
|
+
);
|
|
964
|
+
const omitted = __test!.withRuntimeSettings({}, { aggregateMode: { enabled: true } });
|
|
965
|
+
|
|
966
|
+
expect(enabled.aggregateMode?.constructions?.loggedInSegment).toBe(true);
|
|
967
|
+
expect(omitted.aggregateMode?.constructions?.loggedInSegment).toBeUndefined();
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
it("reads the cart attribute write owner and scope from fetched runtime settings", async () => {
|
|
972
|
+
const { __test } = await import("./index");
|
|
973
|
+
const external = __test!.withRuntimeSettings(
|
|
974
|
+
{},
|
|
975
|
+
{ commerce: { cartAttributeWrite: "external", cartAttributeScope: "all" } },
|
|
976
|
+
);
|
|
977
|
+
const malformed = __test!.withRuntimeSettings(
|
|
978
|
+
{},
|
|
979
|
+
{ commerce: { cartAttributeWrite: "unexpected", cartAttributeScope: "unexpected" } },
|
|
980
|
+
);
|
|
981
|
+
const legacyCached = __test!.withRuntimeSettings(
|
|
982
|
+
{ cartAttributeWrite: "external", cartAttributeScope: "all" },
|
|
983
|
+
{ commerce: {} },
|
|
984
|
+
);
|
|
985
|
+
const freshSdk = __test!.withRuntimeSettings(
|
|
986
|
+
{ cartAttributeWrite: "external", cartAttributeScope: "all" },
|
|
987
|
+
{ commerce: { cartAttributeWrite: "sdk", cartAttributeScope: "consented" } },
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
expect(external.cartAttributeWrite).toBe("external");
|
|
991
|
+
expect(external.cartAttributeScope).toBe("all");
|
|
992
|
+
expect(malformed.cartAttributeWrite).toBe("sdk");
|
|
993
|
+
expect(malformed.cartAttributeScope).toBe("consented");
|
|
994
|
+
expect(legacyCached.cartAttributeWrite).toBe("external");
|
|
995
|
+
expect(legacyCached.cartAttributeScope).toBe("all");
|
|
996
|
+
expect(freshSdk.cartAttributeWrite).toBe("sdk");
|
|
997
|
+
expect(freshSdk.cartAttributeScope).toBe("consented");
|
|
998
|
+
});
|
|
999
|
+
});
|