@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
@@ -11,6 +11,7 @@ const session: ActiveEditorSession = {
11
11
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
12
12
  shopId: "shop-1",
13
13
  experimentId: "experiment-1",
14
+ experimentStatus: "draft",
14
15
  initialVariationId: "variation-1",
15
16
  targetUrl: "https://shop.example/",
16
17
  targetOrigin: "https://shop.example",
@@ -26,6 +27,15 @@ function fakeApi(artifacts: EditorArtifacts = { mutations: [], customCss: "", cu
26
27
  saveArtifacts: vi.fn(async (_variationId: string, next: EditorArtifacts) => ({
27
28
  artifactHash: "hash-2",
28
29
  reviewStatus: "approved",
30
+ outcome: {
31
+ saved: true as const,
32
+ artifactHash: "hash-2",
33
+ publication: {
34
+ status: "ok" as const,
35
+ code: "variation_saved",
36
+ message: "The treatment was saved.",
37
+ },
38
+ },
29
39
  warningIssues: [],
30
40
  artifacts: next,
31
41
  })),
@@ -102,18 +112,12 @@ describe("EditorController", () => {
102
112
  });
103
113
 
104
114
  it("serializes autosaves and sends the latest draft with the newest hash", async () => {
105
- let resolveFirst!: (value: {
106
- artifactHash: string;
107
- reviewStatus: string;
108
- warningIssues: never[];
109
- artifacts: EditorArtifacts;
110
- }) => void;
111
- const firstSave = new Promise<{
112
- artifactHash: string;
113
- reviewStatus: string;
114
- warningIssues: never[];
115
- artifacts: EditorArtifacts;
116
- }>((resolve) => { resolveFirst = resolve; });
115
+ let resolveFirst!: (
116
+ value: Awaited<ReturnType<EditorApi["saveArtifacts"]>>,
117
+ ) => void;
118
+ const firstSave = new Promise<
119
+ Awaited<ReturnType<EditorApi["saveArtifacts"]>>
120
+ >((resolve) => { resolveFirst = resolve; });
117
121
  const api = fakeApi();
118
122
  api.saveArtifacts.mockImplementationOnce(() => firstSave);
119
123
  const controller = new EditorController(session, "https://app.example", api as unknown as EditorApi);
@@ -130,6 +134,15 @@ describe("EditorController", () => {
130
134
  resolveFirst({
131
135
  artifactHash: "hash-2",
132
136
  reviewStatus: "approved",
137
+ outcome: {
138
+ saved: true,
139
+ artifactHash: "hash-2",
140
+ publication: {
141
+ status: "ok",
142
+ code: "variation_saved",
143
+ message: "The treatment was saved.",
144
+ },
145
+ },
133
146
  warningIssues: [],
134
147
  artifacts: { mutations: [], customCss: "h1{color:blue}", customJs: "" },
135
148
  });
@@ -146,6 +159,238 @@ describe("EditorController", () => {
146
159
  controller.destroy();
147
160
  });
148
161
 
162
+ it("confirms a running save once and keeps publication contention distinct", async () => {
163
+ const api = fakeApi();
164
+ api.saveArtifacts.mockResolvedValue({
165
+ artifactHash: "hash-2",
166
+ reviewStatus: "approved",
167
+ outcome: {
168
+ saved: true,
169
+ artifactHash: "hash-2",
170
+ publication: {
171
+ status: "pending",
172
+ code: "storefront_publication_pending",
173
+ message: "Saved, but publication is pending.",
174
+ },
175
+ },
176
+ warningIssues: [{ message: "Saved, but publication is pending." }],
177
+ artifacts: { mutations: [], customCss: "h1{color:blue}", customJs: "" },
178
+ });
179
+ const confirmLiveSave = vi.fn(() => true);
180
+ const controller = new EditorController(
181
+ { ...session, experimentStatus: "running" },
182
+ "https://app.example",
183
+ api as unknown as EditorApi,
184
+ () => undefined,
185
+ confirmLiveSave,
186
+ );
187
+ await controller.initialize();
188
+
189
+ controller.setCustomCss("h1{color:blue}");
190
+ await vi.advanceTimersByTimeAsync(700);
191
+ expect(controller.getState()).toMatchObject({
192
+ saveState: "pending",
193
+ saveMessage: "Saved, but publication is pending.",
194
+ });
195
+
196
+ controller.setCustomCss("h1{color:green}");
197
+ await vi.advanceTimersByTimeAsync(700);
198
+ expect(confirmLiveSave).toHaveBeenCalledOnce();
199
+ expect(api.saveArtifacts).toHaveBeenCalledTimes(2);
200
+ controller.destroy();
201
+ });
202
+
203
+ it("renders a soft publication warning as a distinct saved state", async () => {
204
+ const api = fakeApi();
205
+ api.saveArtifacts.mockResolvedValue({
206
+ artifactHash: "hash-2",
207
+ reviewStatus: "approved",
208
+ outcome: {
209
+ saved: true,
210
+ artifactHash: "hash-2",
211
+ publication: {
212
+ status: "warning",
213
+ code: "storefront_publication_incomplete",
214
+ message: "Saved and published, but Shopify synchronization is incomplete.",
215
+ },
216
+ },
217
+ warningIssues: [],
218
+ artifacts: { mutations: [], customCss: "h1{color:blue}", customJs: "" },
219
+ });
220
+ const controller = new EditorController(
221
+ session,
222
+ "https://app.example",
223
+ api as unknown as EditorApi,
224
+ );
225
+ await controller.initialize();
226
+
227
+ controller.setCustomCss("h1{color:blue}");
228
+ await vi.advanceTimersByTimeAsync(700);
229
+
230
+ expect(controller.getState()).toMatchObject({
231
+ saveState: "warning",
232
+ saveMessage: "Saved and published, but Shopify synchronization is incomplete.",
233
+ });
234
+ controller.destroy();
235
+ });
236
+
237
+ it("requires a new live confirmation after the active variation changes", async () => {
238
+ const api = fakeApi();
239
+ api.loadArtifacts.mockImplementation(async (variationId: string) => ({
240
+ variation: {
241
+ id: variationId,
242
+ name: variationId,
243
+ artifactHash: `hash-${variationId}`,
244
+ reviewStatus: "approved",
245
+ },
246
+ artifacts: { mutations: [], customCss: "", customJs: "" },
247
+ }));
248
+ const confirmLiveSave = vi.fn(() => true);
249
+ const controller = new EditorController(
250
+ {
251
+ ...session,
252
+ experimentStatus: "running",
253
+ variations: [
254
+ { id: "variation-1", name: "First" },
255
+ { id: "variation-2", name: "Second" },
256
+ ],
257
+ },
258
+ "https://app.example",
259
+ api as unknown as EditorApi,
260
+ () => undefined,
261
+ confirmLiveSave,
262
+ );
263
+ await controller.initialize();
264
+
265
+ controller.setCustomCss("h1{color:blue}");
266
+ await vi.advanceTimersByTimeAsync(700);
267
+ await controller.selectVariation("variation-2");
268
+ controller.setCustomCss("h1{color:green}");
269
+ await vi.advanceTimersByTimeAsync(700);
270
+
271
+ expect(confirmLiveSave).toHaveBeenCalledTimes(2);
272
+ controller.destroy();
273
+ });
274
+
275
+ it("adopts a committed error hash and retries from the new hash", async () => {
276
+ const api = fakeApi();
277
+ api.saveArtifacts.mockRejectedValueOnce(
278
+ new EditorApiError(
279
+ "Saved, but storefront publication failed.",
280
+ 400,
281
+ {
282
+ ok: false,
283
+ saved: true,
284
+ artifact_hash: "hash-after-save",
285
+ publication: {
286
+ status: "failed",
287
+ code: "storefront_publication_failed",
288
+ message: "Saved, but storefront publication failed.",
289
+ },
290
+ reviewStatus: "needs_review",
291
+ },
292
+ ),
293
+ );
294
+ const controller = new EditorController(
295
+ session,
296
+ "https://app.example",
297
+ api as unknown as EditorApi,
298
+ );
299
+ await controller.initialize();
300
+
301
+ controller.setCustomCss("h1{color:blue}");
302
+ await vi.advanceTimersByTimeAsync(700);
303
+ expect(controller.getState()).toMatchObject({
304
+ variation: {
305
+ artifactHash: "hash-after-save",
306
+ reviewStatus: "needs_review",
307
+ },
308
+ saveState: "publication_failed",
309
+ saveMessage: "Saved, but storefront publication failed.",
310
+ });
311
+
312
+ controller.setCustomCss("h1{color:green}");
313
+ await vi.advanceTimersByTimeAsync(700);
314
+ expect(api.saveArtifacts).toHaveBeenNthCalledWith(
315
+ 2,
316
+ "variation-1",
317
+ expect.objectContaining({ customCss: "h1{color:green}" }),
318
+ "hash-after-save",
319
+ );
320
+ expect(controller.getState().saveState).toBe("saved");
321
+ controller.destroy();
322
+ });
323
+
324
+ it("does not write when the first running save confirmation is declined", async () => {
325
+ const api = fakeApi();
326
+ const controller = new EditorController(
327
+ { ...session, experimentStatus: "running" },
328
+ "https://app.example",
329
+ api as unknown as EditorApi,
330
+ () => undefined,
331
+ () => false,
332
+ );
333
+ await controller.initialize();
334
+
335
+ controller.setCustomCss("h1{color:blue}");
336
+ await vi.advanceTimersByTimeAsync(700);
337
+
338
+ expect(api.saveArtifacts).not.toHaveBeenCalled();
339
+ expect(controller.getState()).toMatchObject({
340
+ saveState: "idle",
341
+ saveMessage: "Live save cancelled. Your changes are still unsaved.",
342
+ });
343
+ controller.destroy();
344
+ });
345
+
346
+ it("saves an on-store QA experiment without a live-save confirmation", async () => {
347
+ const api = fakeApi();
348
+ const confirmLiveSave = vi.fn(() => false);
349
+ const controller = new EditorController(
350
+ { ...session, experimentStatus: "qa" },
351
+ "https://app.example",
352
+ api as unknown as EditorApi,
353
+ () => undefined,
354
+ confirmLiveSave,
355
+ );
356
+ await controller.initialize();
357
+
358
+ controller.setCustomCss("h1{color:blue}");
359
+ await vi.advanceTimersByTimeAsync(700);
360
+
361
+ expect(confirmLiveSave).not.toHaveBeenCalled();
362
+ expect(api.saveArtifacts).toHaveBeenCalledOnce();
363
+ expect(controller.getState().saveState).toBe("saved");
364
+ controller.destroy();
365
+ });
366
+
367
+ it.each([null, undefined, "unknown"])(
368
+ "fails closed when the experiment status is %s",
369
+ async (experimentStatus) => {
370
+ const api = fakeApi();
371
+ const confirmLiveSave = vi.fn(() => false);
372
+ const controller = new EditorController(
373
+ { ...session, experimentStatus },
374
+ "https://app.example",
375
+ api as unknown as EditorApi,
376
+ () => undefined,
377
+ confirmLiveSave,
378
+ );
379
+ await controller.initialize();
380
+
381
+ controller.setCustomCss("h1{color:blue}");
382
+ await vi.advanceTimersByTimeAsync(700);
383
+
384
+ expect(confirmLiveSave).toHaveBeenCalledOnce();
385
+ expect(api.saveArtifacts).not.toHaveBeenCalled();
386
+ expect(controller.getState()).toMatchObject({
387
+ saveState: "idle",
388
+ saveMessage: "Live save cancelled. Your changes are still unsaved.",
389
+ });
390
+ controller.destroy();
391
+ },
392
+ );
393
+
149
394
  it("ignores a stale variation response after a faster switch", async () => {
150
395
  let resolveFirst!: (value: Awaited<ReturnType<EditorApi["loadArtifacts"]>>) => void;
151
396
  const first = new Promise<Awaited<ReturnType<EditorApi["loadArtifacts"]>>>((resolve) => {
@@ -249,16 +494,54 @@ describe("EditorController", () => {
249
494
  const mutation = {
250
495
  selector: "#title",
251
496
  action: "html" as const,
252
- html: `<strong onclick="steal()">Expected</strong><script>steal()</script>`,
497
+ html: `<strong onclick="steal()">Expected</strong><iframe srcdoc="steal()"></iframe><a href="data:text/html,steal()">link</a><script>steal()</script>`,
253
498
  };
254
499
 
255
500
  title.innerHTML = `<em>Different non-empty HTML</em>`;
256
501
  expect(verify(mutation, 1, 1)).toBe(false);
257
- title.innerHTML = `<strong>Expected</strong>`;
502
+ title.innerHTML = `<strong>Expected</strong><iframe></iframe><a>link</a>`;
258
503
  expect(verify(mutation, 1, 1)).toBe(true);
259
504
  controller.destroy();
260
505
  });
261
506
 
507
+ it("verifies only safe attribute writes and dangerous attribute removals", async () => {
508
+ const api = fakeApi();
509
+ const controller = new EditorController(session, "https://app.example", api as unknown as EditorApi);
510
+ await controller.initialize();
511
+ const title = document.getElementById("title")!;
512
+
513
+ const mixedAudit = controller.applyCommandArtifacts({
514
+ mutations: [{
515
+ selector: "#title",
516
+ action: "attribute",
517
+ attributes: { class: "badge", onclick: "steal()" },
518
+ }],
519
+ customCss: "",
520
+ customJs: "",
521
+ });
522
+
523
+ expect(title.getAttribute("class")).toBe("badge");
524
+ expect(title.hasAttribute("onclick")).toBe(false);
525
+ expect(mixedAudit).toMatchObject({ ok: true });
526
+ expect(mixedAudit.mutationAudit[0]).toMatchObject({ effectVerified: true });
527
+
528
+ title.setAttribute("onclick", "existingHandler()");
529
+ const removalAudit = controller.applyCommandArtifacts({
530
+ mutations: [{
531
+ selector: "#title",
532
+ action: "attribute",
533
+ attributes: { onclick: null },
534
+ }],
535
+ customCss: "",
536
+ customJs: "",
537
+ });
538
+
539
+ expect(title.hasAttribute("onclick")).toBe(false);
540
+ expect(removalAudit).toMatchObject({ ok: true });
541
+ expect(removalAudit.mutationAudit[0]).toMatchObject({ effectVerified: true });
542
+ controller.destroy();
543
+ });
544
+
262
545
  it("bounds session revocation before reloading on exit", async () => {
263
546
  const api = fakeApi();
264
547
  api.revoke.mockImplementationOnce(() => new Promise<void>(() => {}));
@@ -1,5 +1,5 @@
1
1
  import { createMutationApplicationContext, type MutationApplicationContext } from "../mutate";
2
- import { sanitizeHtml } from "../sanitize";
2
+ import { isSafeMutationAttribute, sanitizeHtml } from "../sanitize";
3
3
  import type { Mutation } from "../types";
4
4
  import { EditorApi, EditorApiError } from "./api";
5
5
  import { ArtifactHistory, removeMutationAt, removeSelectorMutations, upsertMutation } from "./artifacts";
@@ -16,6 +16,7 @@ import {
16
16
  const EDITOR_CSS_ID = "apex-editor-draft-css";
17
17
  const AUTOSAVE_MS = 700;
18
18
  const EXIT_REVOKE_TIMEOUT_MS = 1_500;
19
+ const NON_LIVE_EDITOR_STATUSES = new Set(["draft", "awaiting_client_approval", "changes_requested", "client_approved", "qa", "ready_to_launch", "paused", "completed"]);
19
20
 
20
21
  export type ArtifactCommandAudit = {
21
22
  ok: boolean;
@@ -68,6 +69,7 @@ export class EditorController {
68
69
  private saveQueue: Promise<void> = Promise.resolve();
69
70
  private revision = 0;
70
71
  private variationRequest = 0;
72
+ private liveSaveConfirmed = false;
71
73
  private readonly cleanups = new Set<() => void>();
72
74
 
73
75
  constructor(
@@ -75,6 +77,7 @@ export class EditorController {
75
77
  readonly apiOrigin: string,
76
78
  private readonly api = new EditorApi(apiOrigin, session),
77
79
  private readonly reload = () => location.reload(),
80
+ private readonly confirmLiveSave = (message: string) => window.confirm(message),
78
81
  ) {}
79
82
 
80
83
  getState(): EditorState {
@@ -92,6 +95,9 @@ export class EditorController {
92
95
  }
93
96
 
94
97
  async selectVariation(variationId: string): Promise<void> {
98
+ if (this.state.variation?.id !== variationId) {
99
+ this.liveSaveConfirmed = false;
100
+ }
95
101
  const request = ++this.variationRequest;
96
102
  this.cancelSave();
97
103
  this.patch({ saveState: "idle", saveMessage: "Loading draft..." });
@@ -368,6 +374,22 @@ export class EditorController {
368
374
  const expectedHash = this.state.variation.artifactHash;
369
375
  const snapshot = cloneArtifacts(this.state.artifacts);
370
376
  const saveRevision = this.revision;
377
+ if (
378
+ !NON_LIVE_EDITOR_STATUSES.has(this.session.experimentStatus ?? "") &&
379
+ !this.liveSaveConfirmed
380
+ ) {
381
+ const confirmed = this.confirmLiveSave(
382
+ "This test is live. Saving publishes this treatment to real visitors, and the result will span both treatment versions. Save now?",
383
+ );
384
+ if (!confirmed) {
385
+ this.patch({
386
+ saveState: "idle",
387
+ saveMessage: "Live save cancelled. Your changes are still unsaved.",
388
+ });
389
+ return;
390
+ }
391
+ this.liveSaveConfirmed = true;
392
+ }
371
393
  this.patch({ saveState: "saving", saveMessage: "Saving..." });
372
394
  try {
373
395
  const result = await this.api.saveArtifacts(variationId, snapshot, expectedHash);
@@ -381,15 +403,50 @@ export class EditorController {
381
403
  reviewStatus: result.reviewStatus,
382
404
  },
383
405
  artifacts: changedDuringSave ? this.state.artifacts : cloneArtifacts(result.artifacts),
384
- saveState: changedDuringSave ? "idle" : "saved",
406
+ saveState: changedDuringSave
407
+ ? "idle"
408
+ : result.outcome.publication?.status === "pending"
409
+ ? "pending"
410
+ : result.outcome.publication?.status === "warning"
411
+ ? "warning"
412
+ : "saved",
385
413
  saveMessage: changedDuringSave
386
414
  ? "Saving latest changes..."
387
- : result.warningIssues[0]?.message ?? "Saved",
415
+ : result.outcome.publication?.status === "pending"
416
+ ? result.warningIssues[0]?.message ??
417
+ "Saved. Storefront publication is pending and will retry automatically."
418
+ : result.outcome.publication?.status === "warning"
419
+ ? result.outcome.publication.message
420
+ : result.warningIssues[0]?.message ?? "Saved",
388
421
  };
389
422
  this.emit();
390
423
  } catch (error) {
391
424
  if (saveVariationRequest !== this.variationRequest || this.state.variation?.id !== variationId) return;
392
- if (error instanceof EditorApiError && error.status === 409) {
425
+ if (
426
+ error instanceof EditorApiError &&
427
+ error.outcome?.saved === true &&
428
+ error.outcome.artifactHash
429
+ ) {
430
+ this.state = {
431
+ ...this.state,
432
+ variation: {
433
+ ...this.state.variation!,
434
+ artifactHash: error.outcome.artifactHash,
435
+ reviewStatus:
436
+ typeof error.payload?.reviewStatus === "string"
437
+ ? error.payload.reviewStatus
438
+ : this.state.variation!.reviewStatus,
439
+ },
440
+ saveState:
441
+ error.outcome.publication?.status === "pending"
442
+ ? "pending"
443
+ : error.outcome.publication?.status === "warning"
444
+ ? "warning"
445
+ : "publication_failed",
446
+ saveMessage: error.outcome.publication?.message ?? error.message,
447
+ };
448
+ this.emit();
449
+ } else if (error instanceof EditorApiError && error.status === 409) {
393
450
  this.patch({
394
451
  saveState: "conflict",
395
452
  saveMessage: "Draft changed elsewhere. Reload this variation before saving.",
@@ -559,7 +616,14 @@ export class EditorController {
559
616
  ...(mutation.attribute ? { [mutation.attribute.name]: mutation.attribute.value } : {}),
560
617
  ...(mutation.attributes ?? {}),
561
618
  };
562
- return elements.every((element) => Object.entries(attributes).every(
619
+ const requestedAttributes = Object.entries(attributes);
620
+ const safeAttributes = requestedAttributes.filter(
621
+ ([name, value]) => isSafeMutationAttribute(name, value),
622
+ );
623
+ // A mutation whose every requested attribute was dropped as unsafe wrote
624
+ // nothing — it must not verify as success vacuously.
625
+ if (requestedAttributes.length > 0 && safeAttributes.length === 0) return false;
626
+ return elements.every((element) => safeAttributes.every(
563
627
  ([name, value]) => value === null
564
628
  ? !element.hasAttribute(name)
565
629
  : element.getAttribute(name) === value,
@@ -86,6 +86,7 @@ describe("editor session exchange", () => {
86
86
  expiresAt,
87
87
  shopId: "shop-1",
88
88
  experimentId: "experiment-1",
89
+ experimentStatus: "running",
89
90
  initialVariationId: "variation-1",
90
91
  targetUrl: "https://shop.example/products/one",
91
92
  targetOrigin: "https://shop.example",
@@ -109,6 +110,7 @@ describe("editor session exchange", () => {
109
110
  sid: launch.sid,
110
111
  bearer: "editor-bearer",
111
112
  initialVariationId: "variation-1",
113
+ experimentStatus: "running",
112
114
  });
113
115
  expect(postMessage).toHaveBeenCalledWith(
114
116
  {
@@ -79,6 +79,7 @@ function parseSession(value: unknown, sid: string): ActiveEditorSession | null {
79
79
  shopId: parsed.shopId,
80
80
  experimentId: parsed.experimentId,
81
81
  experimentName: readString(record.experimentName),
82
+ experimentStatus: readString(record.experimentStatus),
82
83
  initialVariationId: parsed.initialVariationId,
83
84
  targetUrl: parsed.targetUrl,
84
85
  targetOrigin: parsed.targetOrigin,
@@ -0,0 +1,58 @@
1
+ export type PublicationStatus = "ok" | "pending" | "warning" | "failed";
2
+
3
+ export type VariationSaveOutcome = {
4
+ saved: boolean;
5
+ artifactHash: string | null;
6
+ publication: {
7
+ status: PublicationStatus;
8
+ code: string;
9
+ message: string;
10
+ } | null;
11
+ };
12
+
13
+ function record(value: unknown): Record<string, unknown> | null {
14
+ return value && typeof value === "object" && !Array.isArray(value)
15
+ ? (value as Record<string, unknown>)
16
+ : null;
17
+ }
18
+
19
+ export function parseVariationSaveOutcome(
20
+ value: unknown,
21
+ ): VariationSaveOutcome | null {
22
+ const payload = record(value);
23
+ if (!payload || typeof payload.saved !== "boolean") return null;
24
+ if (!payload.saved) {
25
+ return payload.publication === null
26
+ ? { saved: false, artifactHash: null, publication: null }
27
+ : null;
28
+ }
29
+ if (
30
+ typeof payload.artifact_hash !== "string" ||
31
+ !payload.artifact_hash.trim()
32
+ ) {
33
+ return null;
34
+ }
35
+ const publication = record(payload.publication);
36
+ if (
37
+ !publication ||
38
+ (publication.status !== "ok" &&
39
+ publication.status !== "pending" &&
40
+ publication.status !== "warning" &&
41
+ publication.status !== "failed") ||
42
+ typeof publication.code !== "string" ||
43
+ !publication.code ||
44
+ typeof publication.message !== "string" ||
45
+ !publication.message
46
+ ) {
47
+ return null;
48
+ }
49
+ return {
50
+ saved: true,
51
+ artifactHash: payload.artifact_hash,
52
+ publication: {
53
+ status: publication.status,
54
+ code: publication.code,
55
+ message: publication.message,
56
+ },
57
+ };
58
+ }
@@ -7,6 +7,7 @@ export type ActiveEditorSession = {
7
7
  shopId: string;
8
8
  experimentId: string;
9
9
  experimentName?: string | null;
10
+ experimentStatus?: string | null;
10
11
  initialVariationId: string;
11
12
  targetUrl: string;
12
13
  targetOrigin: string;
@@ -51,7 +52,15 @@ export type ClaimedEditorCommand = {
51
52
  expiresAt: string | null;
52
53
  };
53
54
 
54
- export type EditorSaveState = "idle" | "saving" | "saved" | "conflict" | "error";
55
+ export type EditorSaveState =
56
+ | "idle"
57
+ | "saving"
58
+ | "saved"
59
+ | "pending"
60
+ | "warning"
61
+ | "publication_failed"
62
+ | "conflict"
63
+ | "error";
55
64
 
56
65
  export type EditorDeviceState = "closed" | "loading" | "active" | "blocked";
57
66
 
@@ -109,7 +109,8 @@ button:disabled{cursor:not-allowed}
109
109
  .save-chip.unsaved{color:var(--warning)}
110
110
  .save-chip.saving{color:var(--purple200)}
111
111
  .save-chip.saved{color:var(--positive)}
112
- .save-chip.failed,.save-chip.conflict{color:var(--negative-soft);background:rgba(235,82,82,0.10);border-color:rgba(235,82,82,0.25)}
112
+ .save-chip.pending,.save-chip.warning{color:var(--warning);background:rgba(245,166,35,0.10);border-color:rgba(245,166,35,0.25)}
113
+ .save-chip.failed,.save-chip.publication-failed,.save-chip.conflict{color:var(--negative-soft);background:rgba(235,82,82,0.10);border-color:rgba(235,82,82,0.25)}
113
114
  .save-chip .retry{border:none;background:rgba(255,255,255,0.1);color:#fff;font-size:11.5px;font-weight:600;border-radius:5px;padding:2px 7px;margin-left:2px;pointer-events:auto}
114
115
 
115
116
  /* Segmented control */