@drip-apex/sdk 0.1.0 → 0.2.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 +19262 -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 +19246 -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 +16621 -13539
- 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 +27 -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 +2070 -0
- package/src/consent-evaluation-cookiescript.test.ts +50 -0
- package/src/consent-evaluation.ts +138 -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
|
@@ -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!: (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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>(() => {}));
|
package/src/editor/controller.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
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,
|
package/src/editor/index.test.ts
CHANGED
|
@@ -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
|
{
|
package/src/editor/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/editor/types.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
package/src/editor/view-kit.ts
CHANGED
|
@@ -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.
|
|
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 */
|