@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/runtime.test.ts
CHANGED
|
@@ -53,6 +53,26 @@ describe("platform/router runtime", () => {
|
|
|
53
53
|
expect(resolveRouterMode(config, "spa")).toBe("navigation");
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
it("keeps auto-routed SPA page targeting observable without Navigation API", () => {
|
|
57
|
+
const g = globalThis as Record<string, unknown>;
|
|
58
|
+
g.navigation = undefined;
|
|
59
|
+
const config: InitConfig = {
|
|
60
|
+
platformHint: "spa",
|
|
61
|
+
experiments: [{
|
|
62
|
+
id: "exp_page_rule",
|
|
63
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
64
|
+
targeting: {
|
|
65
|
+
pageRules: [{
|
|
66
|
+
id: "products",
|
|
67
|
+
includeRules: [{ type: "simple", pattern: "/products/*" }],
|
|
68
|
+
}],
|
|
69
|
+
},
|
|
70
|
+
}],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
expect(resolveRouterMode(config, resolvePlatform(config))).toBe("history");
|
|
74
|
+
});
|
|
75
|
+
|
|
56
76
|
it("resolves activation mode to observer by default", () => {
|
|
57
77
|
expect(resolveActivationMode({ experiments: [] })).toBe("observer");
|
|
58
78
|
expect(resolveActivationMode({ experiments: [], activationMode: "manual" })).toBe("manual");
|
|
@@ -74,6 +94,112 @@ describe("platform/router runtime", () => {
|
|
|
74
94
|
expect(deps.hash).toBe(true);
|
|
75
95
|
});
|
|
76
96
|
|
|
97
|
+
it("builds path-only URL diff needs from published page targeting rules", () => {
|
|
98
|
+
const deps = computeUrlDiffNeeds([{
|
|
99
|
+
id: "exp_page_rule",
|
|
100
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
101
|
+
targeting: {
|
|
102
|
+
pageRules: [{
|
|
103
|
+
id: "products",
|
|
104
|
+
includeRules: [{ type: "simple", pattern: "/products/*" }],
|
|
105
|
+
}],
|
|
106
|
+
},
|
|
107
|
+
}]);
|
|
108
|
+
|
|
109
|
+
expect(deps).toEqual({ path: true, search: false, hash: false });
|
|
110
|
+
expect(shouldReevaluateForUrlChange(
|
|
111
|
+
"https://example.com/products/a?q=1",
|
|
112
|
+
"https://example.com/collections?q=1",
|
|
113
|
+
deps,
|
|
114
|
+
)).toBe(true);
|
|
115
|
+
expect(shouldReevaluateForUrlChange(
|
|
116
|
+
"https://example.com/products/a?q=1",
|
|
117
|
+
"https://example.com/products/a?q=2",
|
|
118
|
+
deps,
|
|
119
|
+
)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("builds URL diff needs from goal-only Page rules", () => {
|
|
123
|
+
const deps = computeUrlDiffNeeds([{
|
|
124
|
+
id: "exp_goal_page_rule",
|
|
125
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
126
|
+
goalPageRules: [{
|
|
127
|
+
id: "checkout",
|
|
128
|
+
includeRules: [{ type: "simple", pattern: "/checkout?step=*" }],
|
|
129
|
+
}],
|
|
130
|
+
}]);
|
|
131
|
+
|
|
132
|
+
expect(deps).toEqual({ path: true, search: true, hash: false });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reevaluates goal-only Page rules when a force-link signature changes", () => {
|
|
136
|
+
const deps = computeUrlDiffNeeds([{
|
|
137
|
+
id: "exp_goal_page_rule",
|
|
138
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
139
|
+
goalPageRules: [{
|
|
140
|
+
id: "products",
|
|
141
|
+
includeRules: [{ type: "simple", pattern: "/products/*" }],
|
|
142
|
+
}],
|
|
143
|
+
}]);
|
|
144
|
+
|
|
145
|
+
expect(deps).toEqual({ path: true, search: false, hash: false });
|
|
146
|
+
expect(shouldReevaluateForUrlChange(
|
|
147
|
+
"https://example.com/products/a",
|
|
148
|
+
"https://example.com/products/a?drip_force=exp_goal_page_rule:var_1",
|
|
149
|
+
deps,
|
|
150
|
+
)).toBe(true);
|
|
151
|
+
expect(shouldReevaluateForUrlChange(
|
|
152
|
+
"https://example.com/products/a?q=1",
|
|
153
|
+
"https://example.com/products/a?q=2",
|
|
154
|
+
deps,
|
|
155
|
+
)).toBe(false);
|
|
156
|
+
expect(shouldReevaluateForUrlChange(
|
|
157
|
+
"https://example.com/products/a?q=1",
|
|
158
|
+
"https://example.com/collections?q=1",
|
|
159
|
+
deps,
|
|
160
|
+
)).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("builds URL diff needs from URL-backed audience rules and advanced JS", () => {
|
|
164
|
+
const audienceDeps = computeUrlDiffNeeds([{
|
|
165
|
+
id: "exp_audience",
|
|
166
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
167
|
+
targeting: {
|
|
168
|
+
attributes: [{ attribute: "url.path", operator: "contains", value: "/products" }],
|
|
169
|
+
segmentRules: [{
|
|
170
|
+
id: "query",
|
|
171
|
+
rules: {
|
|
172
|
+
version: 1,
|
|
173
|
+
operator: "and",
|
|
174
|
+
conditions: [{ signal: "url.query", operator: "is_set", value: "campaign" }],
|
|
175
|
+
},
|
|
176
|
+
}],
|
|
177
|
+
},
|
|
178
|
+
}]);
|
|
179
|
+
expect(audienceDeps).toEqual({ path: true, search: true, hash: false });
|
|
180
|
+
|
|
181
|
+
const jsDeps = computeUrlDiffNeeds([{
|
|
182
|
+
id: "exp_js",
|
|
183
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
184
|
+
targeting: {
|
|
185
|
+
pageRules: [{ id: "advanced", advancedCondition: { code: "return location.hash === '#sale';" } }],
|
|
186
|
+
},
|
|
187
|
+
}]);
|
|
188
|
+
expect(jsDeps).toEqual({ path: true, search: true, hash: true });
|
|
189
|
+
|
|
190
|
+
const documentJsDeps = computeUrlDiffNeeds([{
|
|
191
|
+
id: "exp_document_js",
|
|
192
|
+
variations: [{ id: "var_1", weight: 1, mutations: [] }],
|
|
193
|
+
targeting: {
|
|
194
|
+
pageRules: [{
|
|
195
|
+
id: "advanced-document",
|
|
196
|
+
advancedCondition: { code: "return document.documentURI.endsWith('#sale');" },
|
|
197
|
+
}],
|
|
198
|
+
},
|
|
199
|
+
}]);
|
|
200
|
+
expect(documentJsDeps).toEqual({ path: true, search: true, hash: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
77
203
|
it("builds URL diff needs from route-captured runtime signals", () => {
|
|
78
204
|
const queryDeps = computeUrlDiffNeeds([], [
|
|
79
205
|
{
|
|
@@ -122,6 +248,14 @@ describe("platform/router runtime", () => {
|
|
|
122
248
|
).toBe(true);
|
|
123
249
|
});
|
|
124
250
|
|
|
251
|
+
it("treats the clear sentinel as an observable force signature", () => {
|
|
252
|
+
expect(shouldReevaluateForUrlChange(
|
|
253
|
+
"https://example.com/products/a?drip_force=exp_1:var_a",
|
|
254
|
+
"https://example.com/products/a?drip_force=clear",
|
|
255
|
+
{ path: false, search: false, hash: false },
|
|
256
|
+
)).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
125
259
|
it("reevaluates when readable force params change without URL targeting deps", () => {
|
|
126
260
|
const deps = { path: false, search: false, hash: false };
|
|
127
261
|
expect(
|
|
@@ -157,13 +291,46 @@ describe("platform/router runtime", () => {
|
|
|
157
291
|
expect(a).toBe(b);
|
|
158
292
|
});
|
|
159
293
|
|
|
160
|
-
it("
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
294
|
+
it("changes the assignment signature when the active URL block changes", () => {
|
|
295
|
+
const first = assignmentSignature([
|
|
296
|
+
{
|
|
297
|
+
experimentId: "exp_a",
|
|
298
|
+
variationId: "var_1",
|
|
299
|
+
activeUrlBlockId: "product-a",
|
|
300
|
+
} as any,
|
|
301
|
+
]);
|
|
302
|
+
const second = assignmentSignature([
|
|
303
|
+
{
|
|
304
|
+
experimentId: "exp_a",
|
|
305
|
+
variationId: "var_1",
|
|
306
|
+
activeUrlBlockId: "product-b",
|
|
307
|
+
} as any,
|
|
308
|
+
]);
|
|
309
|
+
const flat = assignmentSignature([
|
|
310
|
+
{
|
|
311
|
+
experimentId: "exp_a",
|
|
312
|
+
variationId: "var_1",
|
|
313
|
+
} as any,
|
|
314
|
+
]);
|
|
315
|
+
|
|
316
|
+
expect(first).not.toBe(second);
|
|
317
|
+
expect(first).not.toBe(flat);
|
|
318
|
+
expect(first).not.toContain("mutations");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("changes the assignment signature when page activity changes", () => {
|
|
322
|
+
const active = assignmentSignature([
|
|
323
|
+
{ experimentId: "exp_a", variationId: "var_1", pageActive: true } as any,
|
|
324
|
+
]);
|
|
325
|
+
const inactive = assignmentSignature([
|
|
326
|
+
{ experimentId: "exp_a", variationId: "var_1", pageActive: false } as any,
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
expect(active).not.toBe(inactive);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("defaults the surviving empty-project perf flag off", () => {
|
|
333
|
+
const flags = readPerfFlags({ experiments: [] });
|
|
167
334
|
expect(flags.perf_empty_project_v1).toBe(false);
|
|
168
335
|
});
|
|
169
336
|
|
package/src/runtime.ts
CHANGED
|
@@ -17,9 +17,6 @@ type UrlDiffNeeds = {
|
|
|
17
17
|
export type PerfFlagState = Record<PerfFeatureFlag, boolean>;
|
|
18
18
|
|
|
19
19
|
const PERF_FLAG_DEFAULTS: PerfFlagState = {
|
|
20
|
-
perf_platform_router_v1: true,
|
|
21
|
-
perf_shopify_pixel_buffer_v1: true,
|
|
22
|
-
perf_spa_incremental_reapply_v1: true,
|
|
23
20
|
perf_empty_project_v1: false,
|
|
24
21
|
};
|
|
25
22
|
|
|
@@ -157,8 +154,10 @@ function parseMaybeUrl(url: string): URL | null {
|
|
|
157
154
|
}
|
|
158
155
|
|
|
159
156
|
function readForceSignature(params: URLSearchParams): string {
|
|
160
|
-
const canonical = params.get("drip_force")
|
|
161
|
-
if (canonical)
|
|
157
|
+
const canonical = params.get("drip_force");
|
|
158
|
+
if (canonical !== null) {
|
|
159
|
+
return canonical.trim().toLowerCase() === "clear" ? "__drip_force_clear__" : canonical;
|
|
160
|
+
}
|
|
162
161
|
const experimentId =
|
|
163
162
|
params.get("drip_experiment_id") ??
|
|
164
163
|
params.get("drip_experiment") ??
|
|
@@ -179,7 +178,7 @@ function readForceSignature(params: URLSearchParams): string {
|
|
|
179
178
|
function markUrlDepsForPattern(
|
|
180
179
|
deps: UrlDiffNeeds,
|
|
181
180
|
pattern: string,
|
|
182
|
-
type: "regex" | "simple",
|
|
181
|
+
type: "regex" | "simple" | undefined,
|
|
183
182
|
): void {
|
|
184
183
|
if (type === "regex") {
|
|
185
184
|
deps.path = true;
|
|
@@ -192,18 +191,86 @@ function markUrlDepsForPattern(
|
|
|
192
191
|
if (pattern.includes("#")) deps.hash = true;
|
|
193
192
|
}
|
|
194
193
|
|
|
194
|
+
function markUrlDepsForSignal(deps: UrlDiffNeeds, signal: string): void {
|
|
195
|
+
if (signal === "url.path") deps.path = true;
|
|
196
|
+
if (signal === "url.query") deps.search = true;
|
|
197
|
+
if (signal === "url.full") {
|
|
198
|
+
deps.path = true;
|
|
199
|
+
deps.search = true;
|
|
200
|
+
deps.hash = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function jsMayReadUrl(code: string | undefined): boolean {
|
|
205
|
+
// `document` is included because document.documentURI/baseURI expose the
|
|
206
|
+
// full URL without mentioning `location` or `url`.
|
|
207
|
+
return Boolean(code?.trim() && /\b(?:location|url|document)\b/i.test(code));
|
|
208
|
+
}
|
|
209
|
+
|
|
195
210
|
export function computeUrlDiffNeeds(
|
|
196
211
|
experiments: Experiment[],
|
|
197
212
|
signals: InitConfig["signals"] = [],
|
|
198
213
|
): UrlDiffNeeds {
|
|
199
214
|
const deps: UrlDiffNeeds = { path: false, search: false, hash: false };
|
|
215
|
+
let hasUrlTargeting = false;
|
|
200
216
|
for (const exp of experiments) {
|
|
201
217
|
const rules = exp.targeting?.url;
|
|
202
|
-
|
|
203
|
-
|
|
218
|
+
for (const rule of rules ?? []) {
|
|
219
|
+
hasUrlTargeting = true;
|
|
204
220
|
markUrlDepsForPattern(deps, rule.pattern, rule.type);
|
|
205
221
|
if (deps.path && deps.search && deps.hash) return deps;
|
|
206
222
|
}
|
|
223
|
+
for (const rule of [
|
|
224
|
+
...(exp.targeting?.pageRules ?? []),
|
|
225
|
+
...(exp.goalPageRules ?? []),
|
|
226
|
+
]) {
|
|
227
|
+
hasUrlTargeting = true;
|
|
228
|
+
// Published rules without a recognized matcher still depend on the
|
|
229
|
+
// current page. Never let an unknown shape suppress pathname rechecks.
|
|
230
|
+
deps.path = true;
|
|
231
|
+
for (const urlRule of [...(rule.includeRules ?? []), ...(rule.excludeRules ?? [])]) {
|
|
232
|
+
markUrlDepsForPattern(deps, urlRule.pattern, urlRule.type);
|
|
233
|
+
}
|
|
234
|
+
if (
|
|
235
|
+
jsMayReadUrl(rule.advancedCondition?.code)
|
|
236
|
+
|| jsMayReadUrl(rule.trigger?.triggerJs)
|
|
237
|
+
) {
|
|
238
|
+
deps.path = true;
|
|
239
|
+
deps.search = true;
|
|
240
|
+
deps.hash = true;
|
|
241
|
+
}
|
|
242
|
+
if (deps.path && deps.search && deps.hash) return deps;
|
|
243
|
+
}
|
|
244
|
+
for (const rule of exp.targeting?.attributes ?? []) {
|
|
245
|
+
markUrlDepsForSignal(deps, rule.attribute);
|
|
246
|
+
if (rule.attribute.startsWith("url.")) hasUrlTargeting = true;
|
|
247
|
+
}
|
|
248
|
+
for (const rule of exp.targeting?.segmentRules ?? []) {
|
|
249
|
+
const conditions = [
|
|
250
|
+
...(rule.rules?.conditions ?? []),
|
|
251
|
+
...(rule.rules?.groups ?? []).flat(),
|
|
252
|
+
];
|
|
253
|
+
for (const condition of conditions) {
|
|
254
|
+
markUrlDepsForSignal(deps, condition.signal);
|
|
255
|
+
if (condition.signal.startsWith("url.")) hasUrlTargeting = true;
|
|
256
|
+
if (jsMayReadUrl(condition.js?.code) || jsMayReadUrl(condition.js?.resetCode)) {
|
|
257
|
+
hasUrlTargeting = true;
|
|
258
|
+
deps.path = true;
|
|
259
|
+
deps.search = true;
|
|
260
|
+
deps.hash = true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (deps.path && deps.search && deps.hash) return deps;
|
|
264
|
+
}
|
|
265
|
+
for (const variation of exp.variations) {
|
|
266
|
+
for (const block of variation.urlBlocks ?? []) {
|
|
267
|
+
for (const rule of block.urlRules) {
|
|
268
|
+
hasUrlTargeting = true;
|
|
269
|
+
markUrlDepsForPattern(deps, rule.pattern, rule.type);
|
|
270
|
+
if (deps.path && deps.search && deps.hash) return deps;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
207
274
|
}
|
|
208
275
|
|
|
209
276
|
for (const definition of signals ?? []) {
|
|
@@ -219,6 +286,7 @@ export function computeUrlDiffNeeds(
|
|
|
219
286
|
if (deps.path && deps.search && deps.hash) return deps;
|
|
220
287
|
}
|
|
221
288
|
|
|
289
|
+
if (hasUrlTargeting && !deps.path && !deps.search && !deps.hash) deps.path = true;
|
|
222
290
|
return deps;
|
|
223
291
|
}
|
|
224
292
|
|
|
@@ -231,21 +299,27 @@ export function shouldReevaluateForUrlChange(
|
|
|
231
299
|
const prev = parseMaybeUrl(previousUrl);
|
|
232
300
|
const next = parseMaybeUrl(nextUrl);
|
|
233
301
|
if (!prev || !next) return true;
|
|
302
|
+
const prevForce = readForceSignature(prev.searchParams);
|
|
303
|
+
const nextForce = readForceSignature(next.searchParams);
|
|
304
|
+
if (prevForce !== nextForce) return true;
|
|
234
305
|
if (deps.path && prev.pathname !== next.pathname) return true;
|
|
235
306
|
if (deps.search && prev.search !== next.search) return true;
|
|
236
307
|
if (deps.hash && prev.hash !== next.hash) return true;
|
|
237
|
-
if (!deps.path && !deps.search && !deps.hash) {
|
|
238
|
-
const prevForce = readForceSignature(prev.searchParams);
|
|
239
|
-
const nextForce = readForceSignature(next.searchParams);
|
|
240
|
-
return prevForce !== nextForce;
|
|
241
|
-
}
|
|
242
308
|
return false;
|
|
243
309
|
}
|
|
244
310
|
|
|
245
311
|
export function assignmentSignature(assignments: Assignment[]): string {
|
|
246
312
|
if (!assignments.length) return "none";
|
|
247
313
|
return assignments
|
|
248
|
-
.map((item) =>
|
|
314
|
+
.map((item) =>
|
|
315
|
+
item.experimentId +
|
|
316
|
+
":" +
|
|
317
|
+
item.variationId +
|
|
318
|
+
":" +
|
|
319
|
+
(item.activeUrlBlockId ? `block:${item.activeUrlBlockId}` : "flat") +
|
|
320
|
+
":" +
|
|
321
|
+
(item.pageActive ? 1 : 0),
|
|
322
|
+
)
|
|
249
323
|
.sort()
|
|
250
324
|
.join("|");
|
|
251
325
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { isSafeMutationAttribute, sanitizeHtml } from "./sanitize";
|
|
4
|
+
|
|
5
|
+
describe("isSafeMutationAttribute", () => {
|
|
6
|
+
it("allows removals without allowing dangerous attribute writes", () => {
|
|
7
|
+
expect(isSafeMutationAttribute("onclick", null)).toBe(true);
|
|
8
|
+
expect(isSafeMutationAttribute("onclick", "x")).toBe(false);
|
|
9
|
+
expect(isSafeMutationAttribute("srcdoc", null)).toBe(true);
|
|
10
|
+
expect(isSafeMutationAttribute("", null)).toBe(false);
|
|
11
|
+
expect(isSafeMutationAttribute("href", "https://example.com")).toBe(true);
|
|
12
|
+
expect(isSafeMutationAttribute("href", "javascript:alert(1)")).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("sanitizeHtml", () => {
|
|
17
|
+
it("unconditionally strips unsafe mutation attributes", () => {
|
|
18
|
+
const sanitized = sanitizeHtml([
|
|
19
|
+
'<iframe srcdoc="<script>alert(1)</script>"></iframe>',
|
|
20
|
+
'<a href="data:text/html,alert(1)" onclick="alert(1)">data</a>',
|
|
21
|
+
'<a href="java\nscript:alert(1)">javascript</a>',
|
|
22
|
+
'<img src="data:image/png;base64,iVBORw0KGgo=">',
|
|
23
|
+
].join(""));
|
|
24
|
+
const template = document.createElement("template");
|
|
25
|
+
template.innerHTML = sanitized;
|
|
26
|
+
expect(template.content.querySelector("iframe")?.hasAttribute("srcdoc")).toBe(false);
|
|
27
|
+
const anchors = template.content.querySelectorAll("a");
|
|
28
|
+
expect(anchors[0]?.hasAttribute("href")).toBe(false);
|
|
29
|
+
expect(anchors[0]?.hasAttribute("onclick")).toBe(false);
|
|
30
|
+
expect(anchors[1]?.hasAttribute("href")).toBe(false);
|
|
31
|
+
expect(template.content.querySelector("img")?.getAttribute("src"))
|
|
32
|
+
.toBe("data:image/png;base64,iVBORw0KGgo=");
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/sanitize.ts
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
const URL_ATTRIBUTES = new Set(["href", "src", "action", "formaction", "xlink:href"]);
|
|
2
|
+
const SAFE_DATA_URL_MEDIA_TYPES = new Set([
|
|
3
|
+
"image/png",
|
|
4
|
+
"image/jpeg",
|
|
5
|
+
"image/gif",
|
|
6
|
+
"image/webp",
|
|
7
|
+
"image/avif",
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function isSafeMutationAttribute(name: string, value: string | null): boolean {
|
|
11
|
+
const normalizedName = name.trim().toLowerCase();
|
|
12
|
+
if (!normalizedName) return false;
|
|
13
|
+
if (value === null) return true;
|
|
14
|
+
if (normalizedName.startsWith("on") || normalizedName === "srcdoc") return false;
|
|
15
|
+
if (!URL_ATTRIBUTES.has(normalizedName)) return true;
|
|
16
|
+
const normalizedValue = value.replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
|
17
|
+
if (normalizedValue.startsWith("javascript:")) return false;
|
|
18
|
+
if (!normalizedValue.startsWith("data:")) return true;
|
|
19
|
+
const mediaType = normalizedValue.slice(5).split(/[;,]/, 1)[0];
|
|
20
|
+
return SAFE_DATA_URL_MEDIA_TYPES.has(mediaType);
|
|
21
|
+
}
|
|
22
|
+
|
|
1
23
|
/**
|
|
2
24
|
* Lightweight HTML sanitizer using the browser's DOMParser.
|
|
3
25
|
* Strips <script> tags, on* event handlers, and javascript: URLs.
|
|
@@ -13,19 +35,8 @@ export function sanitizeHtml(html: string): string {
|
|
|
13
35
|
// Walk every element to strip dangerous attributes
|
|
14
36
|
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
|
15
37
|
for (const attr of Array.from(el.attributes)) {
|
|
16
|
-
|
|
17
|
-
if (attr.name.toLowerCase().startsWith("on")) {
|
|
38
|
+
if (!isSafeMutationAttribute(attr.name, attr.value)) {
|
|
18
39
|
el.removeAttribute(attr.name);
|
|
19
|
-
continue;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Remove javascript: URLs in href, src, action, formaction, xlink:href
|
|
23
|
-
const urlAttrs = ["href", "src", "action", "formaction", "xlink:href"];
|
|
24
|
-
if (urlAttrs.includes(attr.name.toLowerCase())) {
|
|
25
|
-
const value = attr.value.replace(/\s+/g, "").toLowerCase();
|
|
26
|
-
if (value.startsWith("javascript:")) {
|
|
27
|
-
el.removeAttribute(attr.name);
|
|
28
|
-
}
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { setupGoals } from "./goals";
|
|
4
|
+
import { applyMutations, applyToElement, resetAppliedMutations } from "./mutate";
|
|
5
|
+
import { trackAppliedMutation, resetReassertSentinel } from "./reassert-sentinel";
|
|
6
|
+
import { queryAllDeep } from "./shadow";
|
|
7
|
+
|
|
8
|
+
class BookingDrawer extends HTMLElement {
|
|
9
|
+
readonly root = this.attachShadow({ mode: "open" });
|
|
10
|
+
|
|
11
|
+
render(): void {
|
|
12
|
+
this.root.innerHTML = `
|
|
13
|
+
<section class="booking" data-step="control">
|
|
14
|
+
<strong class="title">Control</strong>
|
|
15
|
+
<button class="continue">Continue</button>
|
|
16
|
+
</section>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
renderWithoutContinue(): void {
|
|
20
|
+
this.root.innerHTML = `
|
|
21
|
+
<section class="booking" data-step="control">
|
|
22
|
+
<strong class="title">Control</strong>
|
|
23
|
+
</section>`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!customElements.get("booking-drawer")) customElements.define("booking-drawer", BookingDrawer);
|
|
28
|
+
|
|
29
|
+
function fixture(): BookingDrawer {
|
|
30
|
+
const drawer = document.createElement("booking-drawer") as BookingDrawer;
|
|
31
|
+
drawer.render();
|
|
32
|
+
document.body.appendChild(drawer);
|
|
33
|
+
queryAllDeep("[data-shadow-registry-probe]");
|
|
34
|
+
return drawer;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("open shadow DOM support", () => {
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
setupGoals([], [], vi.fn());
|
|
40
|
+
resetReassertSentinel();
|
|
41
|
+
resetAppliedMutations();
|
|
42
|
+
document.body.innerHTML = "";
|
|
43
|
+
localStorage.clear();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("applies text, html, and attribute mutations inside an open root", () => {
|
|
47
|
+
const drawer = fixture();
|
|
48
|
+
applyMutations([
|
|
49
|
+
{ selector: ".title", action: "text", text: "Treatment" },
|
|
50
|
+
{ selector: ".continue", action: "html", html: "<span>Book now</span>" },
|
|
51
|
+
{ selector: ".booking", action: "attribute", attributes: { "data-step": "treatment" } },
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
|
|
55
|
+
expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
|
|
56
|
+
expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reasserts text, html, and attribute mutations after the widget replaces its shadow children", async () => {
|
|
60
|
+
const drawer = fixture();
|
|
61
|
+
await Promise.resolve();
|
|
62
|
+
const mutations = [
|
|
63
|
+
{ selector: ".title", action: "text" as const, text: "Treatment" },
|
|
64
|
+
{ selector: ".continue", action: "html" as const, html: "<span>Book now</span>" },
|
|
65
|
+
{ selector: ".booking", action: "attribute" as const, attributes: { "data-step": "treatment" } },
|
|
66
|
+
];
|
|
67
|
+
for (const mutation of mutations) {
|
|
68
|
+
const original = queryAllDeep(mutation.selector)[0];
|
|
69
|
+
applyToElement(mutation, original);
|
|
70
|
+
trackAppliedMutation(original, mutation, {}, (element, treatment) => applyToElement(treatment, element));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
drawer.render();
|
|
74
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
75
|
+
|
|
76
|
+
expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
|
|
77
|
+
expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
|
|
78
|
+
expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
|
|
79
|
+
|
|
80
|
+
// A second full re-render must also reassert: the entry has to migrate to
|
|
81
|
+
// the replacement element's observer root, not stay keyed under the
|
|
82
|
+
// disconnected element (where the next observe() sweep would purge it).
|
|
83
|
+
drawer.render();
|
|
84
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
85
|
+
|
|
86
|
+
expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
|
|
87
|
+
expect(drawer.root.querySelector(".continue")?.innerHTML).toBe("<span>Book now</span>");
|
|
88
|
+
expect(drawer.root.querySelector(".booking")?.getAttribute("data-step")).toBe("treatment");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resurrects a sibling treatment whose replacement appears only in a later re-render", async () => {
|
|
92
|
+
const drawer = fixture();
|
|
93
|
+
await Promise.resolve();
|
|
94
|
+
const mutations = [
|
|
95
|
+
{ selector: ".title", action: "text" as const, text: "Treatment" },
|
|
96
|
+
{ selector: ".continue", action: "text" as const, text: "Book now" },
|
|
97
|
+
];
|
|
98
|
+
for (const mutation of mutations) {
|
|
99
|
+
const original = queryAllDeep(mutation.selector)[0];
|
|
100
|
+
applyToElement(mutation, original);
|
|
101
|
+
trackAppliedMutation(original, mutation, {}, (element, treatment) => applyToElement(treatment, element));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Partial re-render: .title migrates (running observe()'s GC sweep) while
|
|
105
|
+
// .continue has no replacement in this batch — its entry must survive.
|
|
106
|
+
drawer.renderWithoutContinue();
|
|
107
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
108
|
+
expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
|
|
109
|
+
|
|
110
|
+
drawer.render();
|
|
111
|
+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
|
112
|
+
expect(drawer.root.querySelector(".title")?.textContent).toBe("Treatment");
|
|
113
|
+
expect(drawer.root.querySelector(".continue")?.textContent).toBe("Book now");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("discovers hosts that attach a shadow root after insertion", () => {
|
|
117
|
+
const el = document.createElement("div");
|
|
118
|
+
document.body.appendChild(el);
|
|
119
|
+
const root = el.attachShadow({ mode: "open" });
|
|
120
|
+
root.innerHTML = '<button class="late-btn">Go</button>';
|
|
121
|
+
|
|
122
|
+
const found = queryAllDeep(".late-btn");
|
|
123
|
+
expect(found).toHaveLength(1);
|
|
124
|
+
expect(found[0]).toBe(root.querySelector(".late-btn"));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("returns multi-host matches in document order, not registration order", () => {
|
|
128
|
+
const first = fixture();
|
|
129
|
+
const second = fixture();
|
|
130
|
+
// Re-insert the first host at the front: registration order is now
|
|
131
|
+
// (second, first) while document order is (first, second).
|
|
132
|
+
first.remove();
|
|
133
|
+
queryAllDeep("[data-shadow-registry-probe]");
|
|
134
|
+
document.body.insertBefore(first, second);
|
|
135
|
+
|
|
136
|
+
const titles = queryAllDeep(".title");
|
|
137
|
+
expect(titles).toHaveLength(2);
|
|
138
|
+
expect(titles[0]).toBe(first.root.querySelector(".title"));
|
|
139
|
+
expect(titles[1]).toBe(second.root.querySelector(".title"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("fires selector and text click goals from a shadow-internal target", () => {
|
|
143
|
+
const drawer = fixture();
|
|
144
|
+
const callback = vi.fn();
|
|
145
|
+
setupGoals(
|
|
146
|
+
[{ experimentId: "exp", variationId: "var" }],
|
|
147
|
+
[{ id: "exp", goals: [{ id: "click", type: "click", selector: ".booking .continue", textMatch: "Continue" }] }],
|
|
148
|
+
callback,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
(drawer.root.querySelector(".continue") as HTMLButtonElement).click();
|
|
152
|
+
expect(callback).toHaveBeenCalledWith(
|
|
153
|
+
"click",
|
|
154
|
+
{ experimentId: "exp", variationId: "var" },
|
|
155
|
+
{},
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("fires click goals when the composed path starts at a Text node", () => {
|
|
160
|
+
const drawer = fixture();
|
|
161
|
+
const callback = vi.fn();
|
|
162
|
+
setupGoals(
|
|
163
|
+
[{ experimentId: "exp", variationId: "var" }],
|
|
164
|
+
[{ id: "exp", goals: [{ id: "click", type: "click", selector: ".booking .continue" }] }],
|
|
165
|
+
callback,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const textNode = drawer.root.querySelector(".continue")!.firstChild!;
|
|
169
|
+
textNode.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
|
|
170
|
+
expect(callback).toHaveBeenCalledWith(
|
|
171
|
+
"click",
|
|
172
|
+
{ experimentId: "exp", variationId: "var" },
|
|
173
|
+
{},
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("issues no per-host queries on a shadow-free document", () => {
|
|
178
|
+
document.body.innerHTML = '<div class="missing"></div>';
|
|
179
|
+
const documentQuery = vi.spyOn(document, "querySelectorAll");
|
|
180
|
+
|
|
181
|
+
expect(queryAllDeep(".absent")).toEqual([]);
|
|
182
|
+
// One selector query plus the one miss-path host rescan — nothing else.
|
|
183
|
+
expect(documentQuery).toHaveBeenCalledTimes(2);
|
|
184
|
+
expect(documentQuery).toHaveBeenNthCalledWith(1, ".absent");
|
|
185
|
+
expect(documentQuery).toHaveBeenNthCalledWith(2, "*");
|
|
186
|
+
});
|
|
187
|
+
});
|