@drip-apex/sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/README.md +5 -0
  2. package/dist/bundle-report.json +584 -226
  3. package/dist/define.cjs +123 -0
  4. package/dist/define.cjs.map +7 -0
  5. package/dist/define.mjs +102 -0
  6. package/dist/define.mjs.map +7 -0
  7. package/dist/drip.cjs +19283 -0
  8. package/dist/drip.cjs.map +7 -0
  9. package/dist/drip.debug.js +1 -1
  10. package/dist/drip.debug.js.map +1 -1
  11. package/dist/drip.heatmap.js +1 -1
  12. package/dist/drip.heatmap.js.map +1 -1
  13. package/dist/drip.js +1 -1
  14. package/dist/drip.js.map +1 -1
  15. package/dist/drip.live.js +1 -1
  16. package/dist/drip.live.js.map +1 -1
  17. package/dist/drip.mjs +19267 -0
  18. package/dist/drip.mjs.map +7 -0
  19. package/dist/drip.replay-recorder.js +2 -0
  20. package/dist/drip.replay-recorder.js.map +1 -0
  21. package/dist/editor.js +147 -142
  22. package/dist/editor.js.map +4 -4
  23. package/dist/flags.cjs +1788 -0
  24. package/dist/flags.cjs.map +7 -0
  25. package/dist/flags.mjs +1765 -0
  26. package/dist/flags.mjs.map +7 -0
  27. package/dist/index.mjs +12 -0
  28. package/dist/index.mjs.map +2 -2
  29. package/dist/metafiles/define.cjs.meta.json +28 -0
  30. package/dist/metafiles/define.mjs.meta.json +32 -0
  31. package/dist/metafiles/drip.cjs.meta.json +3193 -0
  32. package/dist/metafiles/drip.debug.meta.json +2859 -574
  33. package/dist/metafiles/drip.heatmap.meta.json +2732 -460
  34. package/dist/metafiles/drip.live.meta.json +2732 -460
  35. package/dist/metafiles/drip.meta.json +2859 -574
  36. package/dist/metafiles/drip.mjs.meta.json +3235 -0
  37. package/dist/metafiles/drip.replay-recorder.meta.json +82 -0
  38. package/dist/metafiles/editor.meta.json +67 -27
  39. package/dist/metafiles/flags.cjs.meta.json +220 -0
  40. package/dist/metafiles/flags.mjs.meta.json +225 -0
  41. package/dist/metafiles/index.meta.json +2717 -491
  42. package/dist/metafiles/index.mjs.meta.json +14 -4
  43. package/dist/react/index.js +16636 -13533
  44. package/dist/react/index.js.map +4 -4
  45. package/dist/sri.json +20 -15
  46. package/dist/types/activation-runtime.d.ts +91 -0
  47. package/dist/types/analytics-runtime.d.ts +166 -0
  48. package/dist/types/antiflicker.d.ts +9 -3
  49. package/dist/types/assignment-cookie-sync.d.ts +31 -0
  50. package/dist/types/assignment-runtime.d.ts +257 -0
  51. package/dist/types/assignment-store.d.ts +4 -8
  52. package/dist/types/bucket.d.ts +1 -2
  53. package/dist/types/compact-store.d.ts +1 -0
  54. package/dist/types/config-runtime.d.ts +313 -0
  55. package/dist/types/consent-evaluation.d.ts +28 -0
  56. package/dist/types/css.d.ts +5 -3
  57. package/dist/types/debug-loader.d.ts +2 -0
  58. package/dist/types/decide-assignments.d.ts +31 -0
  59. package/dist/types/delivery-timing.d.ts +8 -0
  60. package/dist/types/early-interaction-capture.d.ts +79 -0
  61. package/dist/types/early-reassert.d.ts +32 -0
  62. package/dist/types/edge-runtime.d.ts +11 -0
  63. package/dist/types/editor/api.d.ts +3 -0
  64. package/dist/types/editor/controller.d.ts +3 -1
  65. package/dist/types/editor/publication-result.d.ts +11 -0
  66. package/dist/types/editor/types.d.ts +2 -1
  67. package/dist/types/editor/view-kit.d.ts +1 -1
  68. package/dist/types/endpoint.d.ts +8 -0
  69. package/dist/types/exclusion-groups.d.ts +4 -7
  70. package/dist/types/flags.d.ts +2 -0
  71. package/dist/types/goal-eligibility.d.ts +4 -0
  72. package/dist/types/goals.d.ts +10 -1
  73. package/dist/types/guards.d.ts +7 -7
  74. package/dist/types/handover.d.ts +12 -1
  75. package/dist/types/hash.d.ts +8 -4
  76. package/dist/types/hydration.d.ts +2 -1
  77. package/dist/types/index.d.ts +24 -128
  78. package/dist/types/init-test-helpers.d.ts +48 -0
  79. package/dist/types/inline-bootstrap.d.ts +26 -0
  80. package/dist/types/js.d.ts +3 -1
  81. package/dist/types/money.d.ts +12 -0
  82. package/dist/types/mutate.d.ts +45 -6
  83. package/dist/types/performance.d.ts +2 -0
  84. package/dist/types/persistence-decoders.d.ts +12 -0
  85. package/dist/types/public-api.d.ts +209 -0
  86. package/dist/types/qa-assistant-state.d.ts +34 -0
  87. package/dist/types/react/index.d.ts +28 -3
  88. package/dist/types/reassert-sentinel.d.ts +6 -0
  89. package/dist/types/redirect.d.ts +48 -0
  90. package/dist/types/replay-recorder-entry.d.ts +1 -0
  91. package/dist/types/replay-signal-state.d.ts +6 -0
  92. package/dist/types/replay-signals.d.ts +13 -0
  93. package/dist/types/replay.d.ts +110 -0
  94. package/dist/types/router.d.ts +12 -2
  95. package/dist/types/runtime-state.d.ts +166 -0
  96. package/dist/types/sanitize.d.ts +1 -0
  97. package/dist/types/shadow.d.ts +3 -0
  98. package/dist/types/shopify-cart.d.ts +25 -1
  99. package/dist/types/storefront-vitals.d.ts +24 -0
  100. package/dist/types/targeting-diagnostics.d.ts +1 -0
  101. package/dist/types/targeting.d.ts +16 -2
  102. package/dist/types/track.d.ts +15 -1
  103. package/dist/types/types.d.ts +115 -9
  104. package/dist/types/wait.d.ts +14 -1
  105. package/dist/types/weekday-schedule.d.ts +3 -0
  106. package/package.json +17 -8
  107. package/src/activation-runtime.ts +2737 -0
  108. package/src/analytics-runtime.ts +2175 -0
  109. package/src/antiflicker.test.ts +216 -44
  110. package/src/antiflicker.ts +103 -28
  111. package/src/artifact-concurrency.test.ts +1091 -0
  112. package/src/assignment-cookie-sync.test.ts +202 -0
  113. package/src/assignment-cookie-sync.ts +189 -0
  114. package/src/assignment-runtime.ts +3204 -0
  115. package/src/assignment-store.test.ts +56 -0
  116. package/src/assignment-store.ts +7 -38
  117. package/src/bucket.ts +3 -18
  118. package/src/commerce-analytics-lifecycle.test.ts +75 -15
  119. package/src/commerce-config-lifecycle.test.ts +81 -1
  120. package/src/commerce-price-preserve.test.ts +133 -0
  121. package/src/commerce-price.test.ts +97 -0
  122. package/src/commerce-price.ts +74 -20
  123. package/src/commerce-shipping.test.ts +53 -0
  124. package/src/commerce-shipping.ts +5 -11
  125. package/src/compact-store.ts +15 -0
  126. package/src/config-fetch.test.ts +480 -9
  127. package/src/config-runtime.ts +2071 -0
  128. package/src/consent-evaluation-borlabs.test.ts +41 -0
  129. package/src/consent-evaluation-cookiescript.test.ts +50 -0
  130. package/src/consent-evaluation.ts +151 -0
  131. package/src/css.test.ts +30 -0
  132. package/src/css.ts +120 -6
  133. package/src/cwv.test.ts +60 -77
  134. package/src/debug-loader.test.ts +214 -0
  135. package/src/debug-loader.ts +41 -0
  136. package/src/decide-assignments.test.ts +196 -0
  137. package/src/decide-assignments.ts +204 -0
  138. package/src/delivery-timing.test.ts +104 -0
  139. package/src/delivery-timing.ts +89 -0
  140. package/src/early-interaction-capture.test.ts +451 -0
  141. package/src/early-interaction-capture.ts +230 -0
  142. package/src/early-reassert.test.ts +207 -0
  143. package/src/early-reassert.ts +152 -0
  144. package/src/edge-adoption.test.ts +435 -0
  145. package/src/edge-runtime.ts +30 -0
  146. package/src/editor/api.test.ts +109 -0
  147. package/src/editor/api.ts +34 -3
  148. package/src/editor/controller.test.ts +297 -14
  149. package/src/editor/controller.ts +69 -5
  150. package/src/editor/index.test.ts +2 -0
  151. package/src/editor/index.ts +1 -0
  152. package/src/editor/publication-result.ts +58 -0
  153. package/src/editor/types.ts +10 -1
  154. package/src/editor/view-kit.ts +2 -1
  155. package/src/editor/view.test.ts +108 -0
  156. package/src/editor/view.ts +49 -7
  157. package/src/endpoint.test.ts +15 -0
  158. package/src/endpoint.ts +10 -0
  159. package/src/exclusion-groups.ts +8 -18
  160. package/src/flags.test.ts +88 -1
  161. package/src/flags.ts +29 -29
  162. package/src/global-runtime-reconciler.test.ts +271 -0
  163. package/src/goal-eligibility.test.ts +29 -0
  164. package/src/goal-eligibility.ts +17 -0
  165. package/src/goals.test.ts +144 -1
  166. package/src/goals.ts +97 -18
  167. package/src/guards.test.ts +99 -1
  168. package/src/guards.ts +34 -15
  169. package/src/handover-cache-order.test.ts +6 -3
  170. package/src/handover.test.ts +154 -8
  171. package/src/handover.ts +122 -12
  172. package/src/hash.ts +29 -54
  173. package/src/holdout.test.ts +26 -0
  174. package/src/hydration.test.ts +73 -2
  175. package/src/hydration.ts +18 -9
  176. package/src/index.ts +130 -7287
  177. package/src/init-analytics.test.ts +1694 -0
  178. package/src/init-anti-flicker.test.ts +1211 -0
  179. package/src/init-cart-attributes.test.ts +2003 -0
  180. package/src/init-config-resolution.test.ts +999 -0
  181. package/src/init-consent.test.ts +971 -0
  182. package/src/init-empty.test.ts +11 -5406
  183. package/src/init-integrations.test.ts +846 -0
  184. package/src/init-page-activation.test.ts +5064 -0
  185. package/src/init-qa-loading.test.ts +740 -0
  186. package/src/init-redirects.test.ts +2156 -0
  187. package/src/init-test-helpers.ts +287 -0
  188. package/src/inline-bootstrap.test.ts +184 -0
  189. package/src/inline-bootstrap.ts +73 -0
  190. package/src/js.test.ts +51 -1
  191. package/src/js.ts +86 -2
  192. package/src/money.test.ts +55 -1
  193. package/src/money.ts +104 -2
  194. package/src/money.web-parity.contract.test.ts +85 -0
  195. package/src/mutate.test.ts +474 -10
  196. package/src/mutate.ts +739 -230
  197. package/src/page-trigger.test.ts +218 -8
  198. package/src/performance.ts +10 -0
  199. package/src/persistence-decoders.ts +18 -0
  200. package/src/public-api.ts +1828 -0
  201. package/src/qa-assistant-state.test.ts +249 -3
  202. package/src/qa-assistant-state.ts +146 -6
  203. package/src/qa-assistant-view.test.ts +220 -2
  204. package/src/qa-assistant-view.ts +102 -5
  205. package/src/react/index.test.tsx +37 -0
  206. package/src/react/index.tsx +19 -3
  207. package/src/reassert-sentinel.test.ts +155 -1
  208. package/src/reassert-sentinel.ts +168 -8
  209. package/src/redirect.test.ts +541 -0
  210. package/src/redirect.ts +572 -0
  211. package/src/replay-recorder-entry.ts +115 -0
  212. package/src/replay-signal-state.ts +100 -0
  213. package/src/replay-signals.test.ts +249 -0
  214. package/src/replay-signals.ts +151 -0
  215. package/src/replay.test.ts +764 -0
  216. package/src/replay.ts +514 -0
  217. package/src/router.test.ts +61 -2
  218. package/src/router.ts +50 -12
  219. package/src/runtime-state.ts +507 -0
  220. package/src/runtime.test.ts +174 -7
  221. package/src/runtime.ts +88 -14
  222. package/src/sanitize.test.ts +34 -0
  223. package/src/sanitize.ts +23 -12
  224. package/src/shadow.test.ts +187 -0
  225. package/src/shadow.ts +99 -0
  226. package/src/shopify-cart.test.ts +733 -23
  227. package/src/shopify-cart.ts +402 -49
  228. package/src/storefront-vitals.test.ts +217 -0
  229. package/src/storefront-vitals.ts +206 -0
  230. package/src/surface-signals.ts +6 -0
  231. package/src/targeting-conformance.test.ts +9 -0
  232. package/src/targeting-diagnostics.ts +38 -8
  233. package/src/targeting-parity-corpus.json +559 -0
  234. package/src/targeting-parity-corpus.test.ts +18 -0
  235. package/src/targeting.detailed.test.ts +105 -0
  236. package/src/targeting.test.ts +211 -0
  237. package/src/targeting.ts +88 -207
  238. package/src/track.test.ts +147 -0
  239. package/src/track.ts +125 -26
  240. package/src/types.ts +120 -13
  241. package/src/wait.test.ts +23 -0
  242. package/src/wait.ts +129 -13
  243. package/src/weekday-delivery.test.ts +56 -0
  244. package/src/weekday-schedule-parity-corpus.json +92 -0
  245. package/src/weekday-schedule.test.ts +69 -0
  246. package/src/weekday-schedule.ts +20 -0
@@ -1,9 +1,21 @@
1
1
  // @vitest-environment jsdom
2
- import { afterEach, describe, expect, it } from "vitest";
3
- import { applyTrackedMutation, createMutationApplicationContext, getMutationKey, isInsertAction, isInsertDuplicate, resetAppliedMutations, __test } from "./mutate";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ addAppliedMutationOwners,
5
+ applyMutations,
6
+ applyTrackedMutation,
7
+ createMutationApplicationContext,
8
+ getMutationKey,
9
+ isInsertAction,
10
+ isInsertDuplicate,
11
+ replayArtifact,
12
+ resetAppliedMutations,
13
+ resetAppliedMutationsForOwner,
14
+ __test,
15
+ } from "./mutate";
4
16
  import { setJsPolicy } from "./js";
5
17
 
6
- const { normalizeCssPropertyName } = __test;
18
+ const { baselineAttributeNames, normalizeCssPropertyName } = __test;
7
19
 
8
20
  describe("normalizeCssPropertyName", () => {
9
21
  it("converts camelCase to kebab-case (backgroundColor)", () => {
@@ -148,7 +160,426 @@ describe("mutation rollback", () => {
148
160
  setJsPolicy("hybrid");
149
161
  });
150
162
 
151
- it("blocks dangerous attributes only under strict policy", () => {
163
+ it("restores only the tracked attribute and preserves host DOM changes", () => {
164
+ const element = document.createElement("div");
165
+ element.setAttribute("data-state", "control");
166
+
167
+ applyTrackedMutation({
168
+ selector: "div",
169
+ action: "attribute",
170
+ attributes: { "data-state": "treatment" },
171
+ }, element);
172
+
173
+ const hostChild = document.createElement("button");
174
+ hostChild.textContent = "Host child";
175
+ element.appendChild(hostChild);
176
+ element.setAttribute("data-host", "live");
177
+
178
+ resetAppliedMutations();
179
+
180
+ expect(element.getAttribute("data-state")).toBe("control");
181
+ expect(element.getAttribute("data-host")).toBe("live");
182
+ expect(element.firstElementChild).toBe(hostChild);
183
+ });
184
+
185
+ it("preserves body content and host listeners when reverting an attribute", () => {
186
+ document.body.innerHTML = '<button type="button">Host action</button>';
187
+ const button = document.body.querySelector("button")!;
188
+ const onClick = vi.fn();
189
+ button.addEventListener("click", onClick);
190
+
191
+ applyTrackedMutation({
192
+ selector: "body",
193
+ action: "attribute",
194
+ attributes: { "data-treatment": "active" },
195
+ }, document.body);
196
+ document.body.append(" current host content");
197
+ const currentHostHtml = document.body.innerHTML;
198
+
199
+ resetAppliedMutations();
200
+
201
+ expect(document.body.innerHTML).toBe(currentHostHtml);
202
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
203
+ expect(onClick).toHaveBeenCalledOnce();
204
+ });
205
+
206
+ it("restores pre-mutation innerHTML for an html action", () => {
207
+ const element = document.createElement("div");
208
+ element.innerHTML = "<strong>Control</strong>";
209
+
210
+ applyTrackedMutation({
211
+ selector: "div",
212
+ action: "html",
213
+ html: "<em>Treatment</em>",
214
+ }, element);
215
+ resetAppliedMutations();
216
+
217
+ expect(element.innerHTML).toBe("<strong>Control</strong>");
218
+ });
219
+
220
+ it("restores text without clobbering host attributes", () => {
221
+ const element = document.createElement("div");
222
+ element.textContent = "Control";
223
+
224
+ applyTrackedMutation({
225
+ selector: "div",
226
+ action: "text",
227
+ text: "Treatment",
228
+ }, element);
229
+ element.setAttribute("data-host", "live");
230
+ resetAppliedMutations();
231
+
232
+ expect(element.textContent).toBe("Control");
233
+ expect(element.getAttribute("data-host")).toBe("live");
234
+ });
235
+
236
+ it("restores pristine markup after text then html mutations", () => {
237
+ const element = document.createElement("div");
238
+ element.innerHTML = "<span>Control</span>";
239
+
240
+ applyTrackedMutation({ selector: "div", action: "text", text: "Treatment" }, element);
241
+ applyTrackedMutation({ selector: "div", action: "html", html: "<em>H</em>" }, element);
242
+ resetAppliedMutations();
243
+
244
+ expect(element.innerHTML).toBe("<span>Control</span>");
245
+ });
246
+
247
+ it("restores child markup after a text mutation", () => {
248
+ const element = document.createElement("div");
249
+ element.innerHTML = "<strong>Control</strong>";
250
+
251
+ applyTrackedMutation({ selector: "div", action: "text", text: "Treatment" }, element);
252
+ resetAppliedMutations();
253
+
254
+ expect(element.innerHTML).toBe("<strong>Control</strong>");
255
+ });
256
+
257
+ it("captures style properties from the original tracked style attribute", () => {
258
+ const element = document.createElement("div");
259
+ element.setAttribute("style", "color: blue");
260
+
261
+ applyTrackedMutation({
262
+ selector: "div",
263
+ action: "attribute",
264
+ attribute: { name: "style", value: "color: red" },
265
+ }, element);
266
+ applyTrackedMutation({
267
+ selector: "div",
268
+ action: "style",
269
+ styles: { color: "green" },
270
+ }, element);
271
+ resetAppliedMutations();
272
+
273
+ expect(element.style.color).toBe("blue");
274
+ });
275
+
276
+ it("does not restore stale content during a later attribute-only teardown", () => {
277
+ const element = document.createElement("div");
278
+ element.textContent = "Control";
279
+
280
+ applyTrackedMutation(
281
+ { selector: "div", action: "text", text: "Content treatment" },
282
+ element,
283
+ undefined,
284
+ ["content-owner"],
285
+ );
286
+ resetAppliedMutationsForOwner("content-owner");
287
+ element.textContent = "Host update";
288
+
289
+ applyTrackedMutation(
290
+ {
291
+ selector: "div",
292
+ action: "attribute",
293
+ attributes: { "data-state": "attribute-treatment" },
294
+ },
295
+ element,
296
+ undefined,
297
+ ["attribute-owner"],
298
+ );
299
+ resetAppliedMutationsForOwner("attribute-owner");
300
+
301
+ expect(element.textContent).toBe("Host update");
302
+ expect(element.getAttribute("data-state")).toBeNull();
303
+ });
304
+
305
+ it("shares a pristine baseline between style properties and the style attribute", () => {
306
+ const element = document.createElement("div");
307
+ element.setAttribute("style", "color: blue");
308
+
309
+ applyTrackedMutation({
310
+ selector: "div",
311
+ action: "style",
312
+ styles: { color: "green" },
313
+ }, element);
314
+ applyTrackedMutation({
315
+ selector: "div",
316
+ action: "attribute",
317
+ attribute: { name: "style", value: "color: red" },
318
+ }, element);
319
+ resetAppliedMutations();
320
+
321
+ expect(element.getAttribute("style")).toBe("color: blue");
322
+ });
323
+
324
+ it("falls back to textContent when innerHTML is not a string", () => {
325
+ const element = {
326
+ innerHTML: undefined,
327
+ textContent: "Control copy",
328
+ } as unknown as Element;
329
+
330
+ applyTrackedMutation(
331
+ { selector: ".hero", action: "text", text: "Treatment copy" },
332
+ element,
333
+ );
334
+ resetAppliedMutations();
335
+
336
+ expect((element as unknown as { innerHTML?: string }).innerHTML).toBeUndefined();
337
+ expect(element.textContent).toBe("Control copy");
338
+ });
339
+
340
+ it("replays surviving html while restoring a removed attribute owner", () => {
341
+ const element = document.createElement("div");
342
+ element.setAttribute("data-state", "control");
343
+ element.innerHTML = "<span>Control</span>";
344
+
345
+ applyTrackedMutation({
346
+ selector: "div",
347
+ action: "attribute",
348
+ attributes: { "data-state": "attribute-treatment" },
349
+ }, element, undefined, ["attribute-owner"]);
350
+ applyTrackedMutation({
351
+ selector: "div",
352
+ action: "html",
353
+ html: "<strong>HTML treatment</strong>",
354
+ }, element, undefined, ["html-owner"]);
355
+
356
+ resetAppliedMutationsForOwner("attribute-owner");
357
+
358
+ expect(element.getAttribute("data-state")).toBe("control");
359
+ expect(element.innerHTML).toBe("<strong>HTML treatment</strong>");
360
+ });
361
+
362
+ it("does not adopt and rewrite a framework-owned replacement during route teardown", () => {
363
+ const originalRoute = document.createElement("main");
364
+ originalRoute.className = "route-shell";
365
+ originalRoute.innerHTML = '<button type="button">Original route</button>';
366
+ document.body.appendChild(originalRoute);
367
+
368
+ applyTrackedMutation(
369
+ {
370
+ selector: ".route-shell",
371
+ action: "html",
372
+ html: '<p data-apex-treatment="true">Treatment</p>',
373
+ },
374
+ originalRoute,
375
+ undefined,
376
+ ["page-home"],
377
+ "experiment-a:variation-a",
378
+ );
379
+
380
+ const frameworkRoute = document.createElement("main");
381
+ frameworkRoute.className = "route-shell";
382
+ const frameworkButton = document.createElement("button");
383
+ frameworkButton.type = "button";
384
+ frameworkButton.textContent = "Return home";
385
+ const onFrameworkClick = vi.fn();
386
+ frameworkButton.addEventListener("click", onFrameworkClick);
387
+ frameworkRoute.appendChild(frameworkButton);
388
+ originalRoute.replaceWith(frameworkRoute);
389
+
390
+ resetAppliedMutationsForOwner("page-home", "experiment-a:variation-a", {
391
+ preserveReplacementTargets: true,
392
+ });
393
+
394
+ expect(document.querySelector(".route-shell")).toBe(frameworkRoute);
395
+ expect(frameworkRoute.firstElementChild).toBe(frameworkButton);
396
+ frameworkButton.click();
397
+ expect(onFrameworkClick).toHaveBeenCalledOnce();
398
+ });
399
+
400
+ it("restores a still-connected target during replacement-preserving teardown", () => {
401
+ const route = document.createElement("main");
402
+ route.className = "connected-route-shell";
403
+ route.innerHTML = '<button type="button">Original route</button>';
404
+ document.body.appendChild(route);
405
+
406
+ applyTrackedMutation(
407
+ {
408
+ selector: ".connected-route-shell",
409
+ action: "html",
410
+ html: '<p data-apex-treatment="true">Treatment</p>',
411
+ },
412
+ route,
413
+ undefined,
414
+ ["page-connected"],
415
+ "experiment-connected:variation-a",
416
+ );
417
+
418
+ expect(route.innerHTML).toBe(
419
+ '<p data-apex-treatment="true">Treatment</p>',
420
+ );
421
+
422
+ resetAppliedMutationsForOwner(
423
+ "page-connected",
424
+ "experiment-connected:variation-a",
425
+ { preserveReplacementTargets: true },
426
+ );
427
+
428
+ expect(route.isConnected).toBe(true);
429
+ expect(route.innerHTML).toBe(
430
+ '<button type="button">Original route</button>',
431
+ );
432
+ });
433
+
434
+ it("keeps a mutation until its final Page owner tears down", () => {
435
+ const element = { textContent: "Control" } as unknown as Element;
436
+ applyTrackedMutation(
437
+ { selector: ".hero", action: "text", text: "Treatment" },
438
+ element,
439
+ undefined,
440
+ ["page-a"],
441
+ );
442
+ addAppliedMutationOwners(new Set(["page-a"]), ["page-b"]);
443
+
444
+ resetAppliedMutationsForOwner("page-a");
445
+ expect((element as unknown as { textContent: string }).textContent).toBe(
446
+ "Treatment",
447
+ );
448
+
449
+ resetAppliedMutationsForOwner("page-b");
450
+ expect((element as unknown as { textContent: string }).textContent).toBe(
451
+ "Control",
452
+ );
453
+ });
454
+
455
+ it("adopts owners only for the requested artifact", () => {
456
+ const first = document.createElement("div");
457
+ const second = document.createElement("div");
458
+ first.textContent = "First control";
459
+ second.textContent = "Second control";
460
+ applyTrackedMutation(
461
+ { selector: ".first", action: "text", text: "First treatment" },
462
+ first,
463
+ undefined,
464
+ ["page-a"],
465
+ "artifact-a",
466
+ );
467
+ applyTrackedMutation(
468
+ { selector: ".second", action: "text", text: "Second treatment" },
469
+ second,
470
+ undefined,
471
+ ["page-a"],
472
+ "artifact-b",
473
+ );
474
+ addAppliedMutationOwners(new Set(["page-a"]), ["page-b"], "artifact-a");
475
+
476
+ resetAppliedMutationsForOwner("page-a");
477
+ expect(first.textContent).toBe("First treatment");
478
+ expect(second.textContent).toBe("Second control");
479
+
480
+ resetAppliedMutationsForOwner("page-b");
481
+ expect(first.textContent).toBe("First control");
482
+ });
483
+
484
+ it("replays a surviving stacked attribute write after the earlier owner tears down", () => {
485
+ const element = document.createElement("div");
486
+ element.setAttribute("data-state", "control");
487
+
488
+ applyTrackedMutation({
489
+ selector: "div",
490
+ action: "attribute",
491
+ attributes: { "data-state": "owner-a" },
492
+ }, element, undefined, ["page-a"]);
493
+ applyTrackedMutation({
494
+ selector: "div",
495
+ action: "attribute",
496
+ attributes: { "data-state": "owner-b" },
497
+ }, element, undefined, ["page-b"]);
498
+
499
+ resetAppliedMutationsForOwner("page-a");
500
+ expect(element.getAttribute("data-state")).toBe("owner-b");
501
+
502
+ resetAppliedMutationsForOwner("page-b");
503
+ expect(element.getAttribute("data-state")).toBe("control");
504
+ });
505
+
506
+ it("shares an HTML attribute baseline across case-variant stacked owners", () => {
507
+ const element = document.createElement("div");
508
+ element.setAttribute("data-state", "control");
509
+
510
+ applyTrackedMutation({
511
+ selector: "div",
512
+ action: "attribute",
513
+ attributes: { "DATA-STATE": "owner-a" },
514
+ }, element, undefined, ["page-a"]);
515
+ applyTrackedMutation({
516
+ selector: "div",
517
+ action: "attribute",
518
+ attributes: { "data-state": "owner-b" },
519
+ }, element, undefined, ["page-b"]);
520
+
521
+ expect(baselineAttributeNames(element)).toEqual(["data-state"]);
522
+ resetAppliedMutationsForOwner("page-a");
523
+ expect(element.getAttribute("data-state")).toBe("owner-b");
524
+ expect(baselineAttributeNames(element)).toEqual(["data-state"]);
525
+
526
+ resetAppliedMutationsForOwner("page-b");
527
+ expect(element.getAttribute("data-state")).toBe("control");
528
+ expect(baselineAttributeNames(element)).toEqual([]);
529
+ });
530
+
531
+ it("preserves mixed-case SVG attribute names in the baseline", () => {
532
+ const element = document.createElementNS("http://www.w3.org/2000/svg", "svg");
533
+ element.setAttribute("viewBox", "0 0 10 10");
534
+
535
+ applyTrackedMutation({
536
+ selector: "svg",
537
+ action: "attribute",
538
+ attributes: { viewBox: "0 0 20 20" },
539
+ }, element, undefined, ["page-svg"]);
540
+
541
+ expect(baselineAttributeNames(element)).toEqual(["viewBox"]);
542
+ resetAppliedMutationsForOwner("page-svg");
543
+ expect(element.getAttribute("viewBox")).toBe("0 0 10 10");
544
+ });
545
+
546
+ it("adopts a duplicate insertion so the nodes survive until the final owner tears down", () => {
547
+ document.body.innerHTML = '<div id="anchor">Anchor</div>';
548
+ const mutation = {
549
+ selector: "#anchor",
550
+ action: "insertAfter" as const,
551
+ html: '<span class="shared-insert">Shared</span>',
552
+ };
553
+
554
+ applyMutations([mutation], { ownerKeys: ["page-a"] });
555
+ applyMutations([mutation], { ownerKeys: ["page-b"] });
556
+ expect(document.querySelectorAll(".shared-insert")).toHaveLength(1);
557
+
558
+ resetAppliedMutationsForOwner("page-a");
559
+ expect(document.querySelectorAll(".shared-insert")).toHaveLength(1);
560
+
561
+ resetAppliedMutationsForOwner("page-b");
562
+ expect(document.querySelectorAll(".shared-insert")).toHaveLength(0);
563
+ });
564
+
565
+ it("rebuilds a treatment after a foreign DOM replacement", () => {
566
+ document.body.innerHTML = '<div id="hero">Control</div>';
567
+ applyMutations([{
568
+ selector: "#hero",
569
+ action: "text",
570
+ text: "Treatment",
571
+ }], {
572
+ artifactId: "artifact-a",
573
+ ownerKeys: ["page-a"],
574
+ });
575
+
576
+ document.getElementById("hero")!.outerHTML = '<div id="hero">Framework</div>';
577
+ replayArtifact("artifact-a");
578
+
579
+ expect(document.getElementById("hero")?.textContent).toBe("Treatment");
580
+ });
581
+
582
+ it("blocks dangerous attributes under every custom JavaScript policy", () => {
152
583
  const element = document.createElement("a");
153
584
  setJsPolicy("strict");
154
585
  applyTrackedMutation({ selector: "a", action: "attribute", attributes: {
@@ -162,8 +593,21 @@ describe("mutation rollback", () => {
162
593
  applyTrackedMutation({ selector: "a", action: "attribute", attributes: {
163
594
  onclick: "alert(1)", href: "javascript:alert(1)",
164
595
  } }, element);
165
- expect(element.getAttribute("onclick")).toBe("alert(1)");
166
- expect(element.getAttribute("href")).toBe("javascript:alert(1)");
596
+ expect(element.getAttribute("onclick")).toBeNull();
597
+ expect(element.getAttribute("href")).toBeNull();
598
+ });
599
+
600
+ it("allows an attribute mutation to remove an existing inline handler", () => {
601
+ const element = document.createElement("button");
602
+ element.setAttribute("onclick", "alert(1)");
603
+
604
+ applyTrackedMutation({
605
+ selector: "button",
606
+ action: "attribute",
607
+ attributes: { onclick: null },
608
+ }, element);
609
+
610
+ expect(element.hasAttribute("onclick")).toBe(false);
167
611
  });
168
612
 
169
613
  it.each([
@@ -171,7 +615,7 @@ describe("mutation rollback", () => {
171
615
  ["data:text/html,<script>alert(1)</script>", false],
172
616
  ["data:image/svg+xml,<svg onload='alert(1)'></svg>", false],
173
617
  [" \tDATA: IMAGE/PNG ;BASE64,iVBORw0KGgo=", true],
174
- ])("applies strict data URL allowlisting to %s", (url, allowed) => {
618
+ ])("applies data URL allowlisting to %s", (url, allowed) => {
175
619
  const element = document.createElement("iframe");
176
620
  setJsPolicy("strict");
177
621
 
@@ -185,20 +629,21 @@ describe("mutation rollback", () => {
185
629
  });
186
630
 
187
631
  it.each(["html", "insertBefore", "insertAfter"] as const)(
188
- "strips executable attributes from %s mutations under strict policy",
632
+ "strips unsafe attributes from %s mutations under the default policy",
189
633
  (action) => {
190
634
  document.body.innerHTML = '<div id="target"></div>';
191
635
  const target = document.querySelector("#target")!;
192
- setJsPolicy("strict");
636
+ setJsPolicy("hybrid");
193
637
  applyTrackedMutation({
194
638
  selector: "#target",
195
639
  action,
196
- html: '<iframe srcdoc="<script>alert(1)</script>"></iframe><a onclick="alert(1)" href="javascript:alert(1)">Safe copy</a>',
640
+ html: '<iframe srcdoc="<script>alert(1)</script>"></iframe><a onclick="alert(1)" href="data:text/html,alert(1)">Safe copy</a><img src="data:image/png;base64,iVBORw0KGgo=">',
197
641
  }, target);
198
642
  const scope = action === "html" ? target : document.body;
199
643
  expect(scope.querySelector("iframe")?.hasAttribute("srcdoc")).toBe(false);
200
644
  expect(scope.querySelector("a")?.hasAttribute("onclick")).toBe(false);
201
645
  expect(scope.querySelector("a")?.hasAttribute("href")).toBe(false);
646
+ expect(scope.querySelector("img")?.getAttribute("src")).toBe("data:image/png;base64,iVBORw0KGgo=");
202
647
  expect(scope.querySelector("a")?.textContent).toBe("Safe copy");
203
648
  },
204
649
  );
@@ -291,6 +736,25 @@ describe("mutation rollback", () => {
291
736
  );
292
737
  });
293
738
 
739
+ it("rolls back a partial multi-mutation application in reverse order and is idempotent", () => {
740
+ document.body.innerHTML = `<h1 id="title">Original</h1>`;
741
+ const context = createMutationApplicationContext();
742
+ const missed: string[] = [];
743
+
744
+ context.apply([
745
+ { selector: "#title", action: "text", text: "First" },
746
+ { selector: "#title", action: "text", text: "Second" },
747
+ { selector: "#missing", action: "text", text: "Never applied" },
748
+ ], { onMiss: (mutation) => missed.push(mutation.selector) });
749
+
750
+ expect(document.getElementById("title")?.textContent).toBe("Second");
751
+ expect(missed).toEqual(["#missing"]);
752
+ context.rollback();
753
+ expect(document.getElementById("title")?.textContent).toBe("Original");
754
+ context.rollback();
755
+ expect(document.getElementById("title")?.textContent).toBe("Original");
756
+ });
757
+
294
758
  it("does not snapshot or remove a pre-existing sibling for an empty insertion", () => {
295
759
  document.body.innerHTML = `<div id="anchor">Anchor</div><p id="existing">Existing</p>`;
296
760
  const context = createMutationApplicationContext();