@drip-apex/sdk 0.1.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/_headers +3 -0
  4. package/dist/bundle-report.json +564 -0
  5. package/dist/drip.debug.js +2 -0
  6. package/dist/drip.debug.js.map +1 -0
  7. package/dist/drip.heatmap.js +2 -0
  8. package/dist/drip.heatmap.js.map +1 -0
  9. package/dist/drip.js +2 -0
  10. package/dist/drip.js.map +1 -0
  11. package/dist/drip.live.js +2 -0
  12. package/dist/drip.live.js.map +1 -0
  13. package/dist/editor.js +480 -0
  14. package/dist/editor.js.map +7 -0
  15. package/dist/index.mjs +645 -0
  16. package/dist/index.mjs.map +7 -0
  17. package/dist/metafiles/drip.debug.meta.json +973 -0
  18. package/dist/metafiles/drip.heatmap.meta.json +918 -0
  19. package/dist/metafiles/drip.live.meta.json +918 -0
  20. package/dist/metafiles/drip.meta.json +973 -0
  21. package/dist/metafiles/editor.meta.json +321 -0
  22. package/dist/metafiles/index.meta.json +1039 -0
  23. package/dist/metafiles/index.mjs.meta.json +78 -0
  24. package/dist/react/index.js +16973 -0
  25. package/dist/react/index.js.map +7 -0
  26. package/dist/sri.json +27 -0
  27. package/dist/types/analytics.d.ts +15 -0
  28. package/dist/types/antiflicker.d.ts +14 -0
  29. package/dist/types/assignment-store.d.ts +30 -0
  30. package/dist/types/audit.d.ts +9 -0
  31. package/dist/types/bucket.d.ts +2 -0
  32. package/dist/types/bucketing-cleanup.d.ts +5 -0
  33. package/dist/types/commerce-gift.d.ts +41 -0
  34. package/dist/types/commerce-price.d.ts +22 -0
  35. package/dist/types/commerce-shipping.d.ts +28 -0
  36. package/dist/types/css.d.ts +5 -0
  37. package/dist/types/cwv.d.ts +10 -0
  38. package/dist/types/data-layer-normalize.d.ts +1 -0
  39. package/dist/types/debug.d.ts +23 -0
  40. package/dist/types/define.d.ts +87 -0
  41. package/dist/types/devtools.d.ts +32 -0
  42. package/dist/types/dom-trigger.d.ts +3 -0
  43. package/dist/types/editor/api.d.ts +37 -0
  44. package/dist/types/editor/artifacts.d.ts +15 -0
  45. package/dist/types/editor/command-runtime.d.ts +48 -0
  46. package/dist/types/editor/controller.d.ts +97 -0
  47. package/dist/types/editor/device-preview.d.ts +23 -0
  48. package/dist/types/editor/index.d.ts +9 -0
  49. package/dist/types/editor/operator.d.ts +39 -0
  50. package/dist/types/editor/picker.d.ts +23 -0
  51. package/dist/types/editor/selector.d.ts +2 -0
  52. package/dist/types/editor/types.d.ts +55 -0
  53. package/dist/types/editor/view-kit.d.ts +33 -0
  54. package/dist/types/editor/view.d.ts +5 -0
  55. package/dist/types/editor-bootstrap.d.ts +37 -0
  56. package/dist/types/exclusion-groups.d.ts +59 -0
  57. package/dist/types/exposure-signals.d.ts +7 -0
  58. package/dist/types/flags.d.ts +65 -0
  59. package/dist/types/goals.d.ts +16 -0
  60. package/dist/types/guards.d.ts +39 -0
  61. package/dist/types/handover.d.ts +10 -0
  62. package/dist/types/hash.d.ts +19 -0
  63. package/dist/types/heatmap.d.ts +8 -0
  64. package/dist/types/hideflicker.d.ts +15 -0
  65. package/dist/types/hydration.d.ts +11 -0
  66. package/dist/types/index.d.ts +144 -0
  67. package/dist/types/js.d.ts +6 -0
  68. package/dist/types/module.d.ts +4 -0
  69. package/dist/types/money.d.ts +14 -0
  70. package/dist/types/mutate.d.ts +34 -0
  71. package/dist/types/observer-mute.d.ts +3 -0
  72. package/dist/types/pii-keys.d.ts +1 -0
  73. package/dist/types/prerequisites.d.ts +12 -0
  74. package/dist/types/qa-assistant-state.d.ts +68 -0
  75. package/dist/types/qa-assistant-view.d.ts +6 -0
  76. package/dist/types/qa-assistant.d.ts +9 -0
  77. package/dist/types/qa-toolbar.d.ts +19 -0
  78. package/dist/types/react/index.d.ts +84 -0
  79. package/dist/types/reassert-sentinel.d.ts +6 -0
  80. package/dist/types/router.d.ts +9 -0
  81. package/dist/types/runtime-tools.d.ts +50 -0
  82. package/dist/types/runtime.d.ts +22 -0
  83. package/dist/types/safe-regex.d.ts +6 -0
  84. package/dist/types/sanitize.d.ts +5 -0
  85. package/dist/types/semantic.d.ts +7 -0
  86. package/dist/types/shopify-cart-events.d.ts +7 -0
  87. package/dist/types/shopify-cart.d.ts +62 -0
  88. package/dist/types/signals.d.ts +31 -0
  89. package/dist/types/surface-signals.d.ts +6 -0
  90. package/dist/types/targeting-diagnostics.d.ts +5 -0
  91. package/dist/types/targeting.d.ts +50 -0
  92. package/dist/types/test-fixtures/commerce.d.ts +12 -0
  93. package/dist/types/track.d.ts +79 -0
  94. package/dist/types/types.d.ts +859 -0
  95. package/dist/types/wait.d.ts +7 -0
  96. package/package.json +74 -0
  97. package/src/analytics.ts +146 -0
  98. package/src/antiflicker.test.ts +127 -0
  99. package/src/antiflicker.ts +147 -0
  100. package/src/assignment-store.test.ts +171 -0
  101. package/src/assignment-store.ts +208 -0
  102. package/src/audit.test.ts +17 -0
  103. package/src/audit.ts +362 -0
  104. package/src/bucket.test.ts +65 -0
  105. package/src/bucket.ts +18 -0
  106. package/src/bucketing-cleanup.test.ts +30 -0
  107. package/src/bucketing-cleanup.ts +72 -0
  108. package/src/bucketing-parity.test.ts +41 -0
  109. package/src/commerce-analytics-lifecycle.test.ts +1416 -0
  110. package/src/commerce-config-lifecycle.test.ts +274 -0
  111. package/src/commerce-epoch-lifecycle.test.ts +62 -0
  112. package/src/commerce-gift.test.ts +1293 -0
  113. package/src/commerce-gift.ts +979 -0
  114. package/src/commerce-offers-display.test.ts +154 -0
  115. package/src/commerce-price.test.ts +1145 -0
  116. package/src/commerce-price.ts +941 -0
  117. package/src/commerce-revocation-lifecycle.test.ts +78 -0
  118. package/src/commerce-shipping.test.ts +630 -0
  119. package/src/commerce-shipping.ts +447 -0
  120. package/src/config-fetch.test.ts +325 -0
  121. package/src/css.ts +54 -0
  122. package/src/cwv.test.ts +95 -0
  123. package/src/cwv.ts +129 -0
  124. package/src/data-layer-normalize.ts +21 -0
  125. package/src/debug.test.ts +83 -0
  126. package/src/debug.ts +138 -0
  127. package/src/define.test.ts +327 -0
  128. package/src/define.ts +208 -0
  129. package/src/devtools.ts +582 -0
  130. package/src/dom-trigger.test.ts +70 -0
  131. package/src/dom-trigger.ts +55 -0
  132. package/src/editor/api.test.ts +154 -0
  133. package/src/editor/api.ts +185 -0
  134. package/src/editor/artifacts.test.ts +57 -0
  135. package/src/editor/artifacts.ts +115 -0
  136. package/src/editor/command-runtime.test.ts +225 -0
  137. package/src/editor/command-runtime.ts +572 -0
  138. package/src/editor/controller.test.ts +371 -0
  139. package/src/editor/controller.ts +596 -0
  140. package/src/editor/device-preview.test.ts +60 -0
  141. package/src/editor/device-preview.ts +120 -0
  142. package/src/editor/index.test.ts +161 -0
  143. package/src/editor/index.ts +169 -0
  144. package/src/editor/operator.test.ts +148 -0
  145. package/src/editor/operator.ts +256 -0
  146. package/src/editor/picker.test.ts +58 -0
  147. package/src/editor/picker.ts +152 -0
  148. package/src/editor/selector.test.ts +44 -0
  149. package/src/editor/selector.ts +110 -0
  150. package/src/editor/types.ts +92 -0
  151. package/src/editor/view-kit.ts +162 -0
  152. package/src/editor/view.test.ts +535 -0
  153. package/src/editor/view.ts +1395 -0
  154. package/src/editor-bootstrap.test.ts +116 -0
  155. package/src/editor-bootstrap.ts +213 -0
  156. package/src/event-schema-contract.test.ts +35 -0
  157. package/src/exclusion-groups.test.ts +383 -0
  158. package/src/exclusion-groups.ts +270 -0
  159. package/src/exposure-signals.test.ts +182 -0
  160. package/src/exposure-signals.ts +110 -0
  161. package/src/flags.test.ts +322 -0
  162. package/src/flags.ts +201 -0
  163. package/src/goals.test.ts +42 -0
  164. package/src/goals.ts +200 -0
  165. package/src/guards.test.ts +249 -0
  166. package/src/guards.ts +250 -0
  167. package/src/handover-cache-order.test.ts +32 -0
  168. package/src/handover.test.ts +349 -0
  169. package/src/handover.ts +309 -0
  170. package/src/hash.ts +214 -0
  171. package/src/heatmap.test.ts +305 -0
  172. package/src/heatmap.ts +373 -0
  173. package/src/hideflicker.ts +21 -0
  174. package/src/hydration.test.ts +252 -0
  175. package/src/hydration.ts +203 -0
  176. package/src/index.ts +7513 -0
  177. package/src/init-empty.test.ts +5572 -0
  178. package/src/js.test.ts +26 -0
  179. package/src/js.ts +61 -0
  180. package/src/module.ts +50 -0
  181. package/src/money.test.ts +35 -0
  182. package/src/money.ts +53 -0
  183. package/src/mutate.test.ts +304 -0
  184. package/src/mutate.ts +469 -0
  185. package/src/observer-mute.test.ts +88 -0
  186. package/src/observer-mute.ts +45 -0
  187. package/src/page-trigger.test.ts +84 -0
  188. package/src/personalization.test.ts +68 -0
  189. package/src/pii-keys.ts +17 -0
  190. package/src/prerequisites.test.ts +39 -0
  191. package/src/prerequisites.ts +94 -0
  192. package/src/qa-assistant-state.test.ts +471 -0
  193. package/src/qa-assistant-state.ts +505 -0
  194. package/src/qa-assistant-view.test.ts +185 -0
  195. package/src/qa-assistant-view.ts +768 -0
  196. package/src/qa-assistant.ts +40 -0
  197. package/src/qa-toolbar.ts +444 -0
  198. package/src/react/index.test.tsx +177 -0
  199. package/src/react/index.tsx +252 -0
  200. package/src/reassert-sentinel.test.ts +314 -0
  201. package/src/reassert-sentinel.ts +191 -0
  202. package/src/router.test.ts +78 -0
  203. package/src/router.ts +92 -0
  204. package/src/runtime-tools.test.ts +112 -0
  205. package/src/runtime-tools.ts +403 -0
  206. package/src/runtime.test.ts +217 -0
  207. package/src/runtime.ts +268 -0
  208. package/src/safe-regex.ts +35 -0
  209. package/src/sanitize.ts +34 -0
  210. package/src/semantic.ts +245 -0
  211. package/src/shopify-cart-events.test.ts +76 -0
  212. package/src/shopify-cart-events.ts +115 -0
  213. package/src/shopify-cart.test.ts +2823 -0
  214. package/src/shopify-cart.ts +1256 -0
  215. package/src/signals.ts +226 -0
  216. package/src/surface-signals.test.ts +83 -0
  217. package/src/surface-signals.ts +165 -0
  218. package/src/targeting-diagnostics.ts +560 -0
  219. package/src/targeting.detailed.test.ts +443 -0
  220. package/src/targeting.test.ts +1054 -0
  221. package/src/targeting.ts +1024 -0
  222. package/src/test-fixtures/commerce.ts +65 -0
  223. package/src/track.test.ts +817 -0
  224. package/src/track.ts +519 -0
  225. package/src/types.ts +991 -0
  226. package/src/wait.test.ts +96 -0
  227. package/src/wait.ts +238 -0
@@ -0,0 +1,596 @@
1
+ import { createMutationApplicationContext, type MutationApplicationContext } from "../mutate";
2
+ import { sanitizeHtml } from "../sanitize";
3
+ import type { Mutation } from "../types";
4
+ import { EditorApi, EditorApiError } from "./api";
5
+ import { ArtifactHistory, removeMutationAt, removeSelectorMutations, upsertMutation } from "./artifacts";
6
+ import { DevicePreview } from "./device-preview";
7
+ import {
8
+ cloneArtifacts,
9
+ EMPTY_EDITOR_ARTIFACTS,
10
+ type ActiveEditorSession,
11
+ type EditorArtifacts,
12
+ type EditorDeviceState,
13
+ type EditorState,
14
+ } from "./types";
15
+
16
+ const EDITOR_CSS_ID = "apex-editor-draft-css";
17
+ const AUTOSAVE_MS = 700;
18
+ const EXIT_REVOKE_TIMEOUT_MS = 1_500;
19
+
20
+ export type ArtifactCommandAudit = {
21
+ ok: boolean;
22
+ mutationAudit: Array<{
23
+ index: number;
24
+ selector: string;
25
+ action: string;
26
+ beforeCount: number;
27
+ afterCount: number;
28
+ effectVerified: boolean;
29
+ error: string | null;
30
+ }>;
31
+ generatedSelectors: string[];
32
+ generatedVisibility: Array<{
33
+ selector: string;
34
+ count: number;
35
+ visibleCount: number;
36
+ matches: Array<{ text: string; visible: boolean }>;
37
+ }>;
38
+ visibleGeneratedCount: number;
39
+ hiddenEffectVerifiedCount: number;
40
+ networkRequired: boolean;
41
+ customJsDeferred: boolean;
42
+ error?: string;
43
+ repairHint?: string;
44
+ };
45
+
46
+ export class EditorController {
47
+ private state: EditorState = {
48
+ variation: null,
49
+ artifacts: cloneArtifacts(EMPTY_EDITOR_ARTIFACTS),
50
+ artifactsEnabled: true,
51
+ pickerActive: false,
52
+ selectedElement: null,
53
+ selectedSelector: null,
54
+ saveState: "idle",
55
+ saveMessage: null,
56
+ dock: "top",
57
+ canUndo: false,
58
+ canRedo: false,
59
+ jsApplied: false,
60
+ reloadRequired: false,
61
+ deviceState: "closed",
62
+ };
63
+ private readonly listeners = new Set<(state: EditorState) => void>();
64
+ private readonly history = new ArtifactHistory();
65
+ private visualContext: MutationApplicationContext | null = null;
66
+ private devicePreview: DevicePreview | null = null;
67
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
68
+ private saveQueue: Promise<void> = Promise.resolve();
69
+ private revision = 0;
70
+ private variationRequest = 0;
71
+ private readonly cleanups = new Set<() => void>();
72
+
73
+ constructor(
74
+ readonly session: ActiveEditorSession,
75
+ readonly apiOrigin: string,
76
+ private readonly api = new EditorApi(apiOrigin, session),
77
+ private readonly reload = () => location.reload(),
78
+ ) {}
79
+
80
+ getState(): EditorState {
81
+ return this.state;
82
+ }
83
+
84
+ subscribe(listener: (state: EditorState) => void): () => void {
85
+ this.listeners.add(listener);
86
+ listener(this.state);
87
+ return () => this.listeners.delete(listener);
88
+ }
89
+
90
+ async initialize(): Promise<void> {
91
+ await this.selectVariation(this.session.initialVariationId);
92
+ }
93
+
94
+ async selectVariation(variationId: string): Promise<void> {
95
+ const request = ++this.variationRequest;
96
+ this.cancelSave();
97
+ this.patch({ saveState: "idle", saveMessage: "Loading draft..." });
98
+ try {
99
+ const loaded = await this.api.loadArtifacts(variationId);
100
+ if (request !== this.variationRequest) return;
101
+ this.history.reset();
102
+ this.revision += 1;
103
+ this.state = {
104
+ ...this.state,
105
+ variation: loaded.variation,
106
+ artifacts: cloneArtifacts(loaded.artifacts),
107
+ selectedElement: null,
108
+ selectedSelector: null,
109
+ saveState: "saved",
110
+ saveMessage: "Saved",
111
+ canUndo: false,
112
+ canRedo: false,
113
+ jsApplied: false,
114
+ reloadRequired: false,
115
+ };
116
+ this.applyVisualArtifacts();
117
+ this.emit();
118
+ } catch (error) {
119
+ if (request !== this.variationRequest) return;
120
+ this.patch({
121
+ saveState: "error",
122
+ saveMessage: error instanceof Error ? error.message : "Draft could not be loaded",
123
+ });
124
+ }
125
+ }
126
+
127
+ setPickerActive(active: boolean): void {
128
+ this.patch({ pickerActive: active });
129
+ }
130
+
131
+ selectElement(element: Element, selector: string): void {
132
+ this.patch({ selectedElement: element, selectedSelector: selector, pickerActive: false });
133
+ }
134
+
135
+ clearSelection(): void {
136
+ this.clearSelectedElement();
137
+ }
138
+
139
+ clearSelectedElement(): void {
140
+ this.patch({ selectedElement: null, selectedSelector: null, pickerActive: false });
141
+ }
142
+
143
+ removeSelector(selector: string): void {
144
+ this.commit(removeSelectorMutations(this.state.artifacts, selector));
145
+ }
146
+
147
+ removeMutation(index: number): void {
148
+ if (!Number.isInteger(index) || index < 0 || index >= this.state.artifacts.mutations.length) return;
149
+ this.commit(removeMutationAt(this.state.artifacts, index));
150
+ }
151
+
152
+ restoreArtifacts(artifacts: EditorArtifacts): void {
153
+ this.commit(cloneArtifacts(artifacts));
154
+ }
155
+
156
+ async extendSession(): Promise<string | null> {
157
+ const expiresAt = await this.api.extendSession();
158
+ if (expiresAt) this.session.expiresAt = expiresAt;
159
+ return expiresAt;
160
+ }
161
+
162
+ async resolveConflict(choice: "mine" | "theirs"): Promise<void> {
163
+ const variation = this.state.variation;
164
+ if (!variation || this.state.saveState !== "conflict") return;
165
+ if (choice === "theirs") {
166
+ await this.selectVariation(variation.id);
167
+ return;
168
+ }
169
+ try {
170
+ const loaded = await this.api.loadArtifacts(variation.id);
171
+ this.state = {
172
+ ...this.state,
173
+ variation: { ...variation, artifactHash: loaded.variation.artifactHash },
174
+ saveState: "idle",
175
+ saveMessage: "Unsaved changes",
176
+ };
177
+ this.emit();
178
+ await this.saveNow();
179
+ } catch (error) {
180
+ this.patch({
181
+ saveState: "error",
182
+ saveMessage: error instanceof Error ? error.message : "Draft could not be saved",
183
+ });
184
+ }
185
+ }
186
+
187
+ toggleArtifacts(enabled: boolean): void {
188
+ this.state = { ...this.state, artifactsEnabled: enabled };
189
+ this.applyVisualArtifacts();
190
+ this.devicePreview?.update(this.state.artifacts, enabled);
191
+ this.emit();
192
+ }
193
+
194
+ setDock(dock: "top" | "bottom"): void {
195
+ this.patch({ dock });
196
+ }
197
+
198
+ setText(value: string): void {
199
+ this.mutateSelected({ action: "setText", text: value });
200
+ }
201
+
202
+ setImageSource(value: string): void {
203
+ this.mutateSelected({ action: "attribute", attributes: { src: value } });
204
+ }
205
+
206
+ setStyle(name: string, value: string): void {
207
+ this.mutateSelected({ action: "setStyle", styles: { [name]: value } });
208
+ }
209
+
210
+ hideSelected(): void {
211
+ this.setStyle("display", "none");
212
+ }
213
+
214
+ removeSelected(): void {
215
+ this.mutateSelected({ action: "remove" });
216
+ }
217
+
218
+ setCustomCss(value: string): void {
219
+ this.commit({ ...this.state.artifacts, customCss: value });
220
+ }
221
+
222
+ setCustomJs(value: string): void {
223
+ this.commit({ ...this.state.artifacts, customJs: value }, false);
224
+ this.patch({ jsApplied: false });
225
+ }
226
+
227
+ applyCustomJs(): void {
228
+ const code = this.state.artifacts.customJs.trim();
229
+ if (!code) return;
230
+ try {
231
+ new Function(`${code}\n//# sourceURL=apex-editor-draft.js`)();
232
+ this.patch({
233
+ jsApplied: true,
234
+ reloadRequired: true,
235
+ saveMessage: "JavaScript applied. Reload required to undo.",
236
+ });
237
+ } catch (error) {
238
+ this.patch({
239
+ saveState: "error",
240
+ saveMessage: error instanceof Error ? error.message : "JavaScript failed",
241
+ });
242
+ }
243
+ }
244
+
245
+ undo(): void {
246
+ const previous = this.history.undo(this.state.artifacts);
247
+ if (previous) this.replaceFromHistory(previous);
248
+ }
249
+
250
+ redo(): void {
251
+ const next = this.history.redo(this.state.artifacts);
252
+ if (next) this.replaceFromHistory(next);
253
+ }
254
+
255
+ attachDevicePreview(devicePreview: DevicePreview): void {
256
+ this.devicePreview = devicePreview;
257
+ }
258
+
259
+ setDeviceState(deviceState: EditorDeviceState): void {
260
+ this.patch({ deviceState });
261
+ }
262
+
263
+ openDevicePreview(): void {
264
+ this.devicePreview?.open(
265
+ this.state.artifactsEnabled ? this.state.artifacts : cloneArtifacts(EMPTY_EDITOR_ARTIFACTS),
266
+ );
267
+ }
268
+
269
+ closeDevicePreview(): void {
270
+ this.devicePreview?.close();
271
+ }
272
+
273
+ addCleanup(cleanup: () => void): () => void {
274
+ this.cleanups.add(cleanup);
275
+ return () => this.cleanups.delete(cleanup);
276
+ }
277
+
278
+ applyCommandArtifacts(artifacts: EditorArtifacts): ArtifactCommandAudit {
279
+ this.visualContext?.rollback();
280
+ this.visualContext = null;
281
+ document.getElementById(EDITOR_CSS_ID)?.remove();
282
+ const beforeCounts = artifacts.mutations.map((mutation) => this.selectorCount(mutation.selector));
283
+ this.state = { ...this.state, artifactsEnabled: true };
284
+ this.commit(cloneArtifacts(artifacts));
285
+ const mutationAudit = artifacts.mutations.map((mutation, index) => {
286
+ const beforeCount = beforeCounts[index];
287
+ const afterCount = this.selectorCount(mutation.selector);
288
+ const effectVerified = this.verifyMutationEffect(mutation, beforeCount, afterCount);
289
+ return {
290
+ index,
291
+ selector: mutation.selector,
292
+ action: mutation.action,
293
+ beforeCount,
294
+ afterCount,
295
+ effectVerified,
296
+ error: beforeCount < 0 ? "Invalid selector" : null,
297
+ };
298
+ });
299
+ const generatedSelectors = Array.from(new Set(
300
+ artifacts.mutations
301
+ .filter((mutation) => mutation.action !== "remove")
302
+ .map((mutation) => mutation.selector),
303
+ ));
304
+ const generatedVisibility = generatedSelectors.map((selector) => {
305
+ const elements = this.selectorElements(selector);
306
+ const matches = elements.slice(0, 8).map((element) => ({
307
+ text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 240),
308
+ visible: this.isVisible(element),
309
+ }));
310
+ return {
311
+ selector,
312
+ count: elements.length,
313
+ visibleCount: matches.filter((match) => match.visible).length,
314
+ matches,
315
+ };
316
+ });
317
+ const failed = mutationAudit.filter((entry) => !entry.effectVerified);
318
+ const visibleGeneratedCount = generatedVisibility.reduce(
319
+ (total, entry) => total + entry.visibleCount,
320
+ 0,
321
+ );
322
+ const networkRequired = /\b(?:fetch|XMLHttpRequest|sendBeacon)\b/.test(artifacts.customJs);
323
+ return {
324
+ ok: failed.length === 0,
325
+ mutationAudit,
326
+ generatedSelectors,
327
+ generatedVisibility,
328
+ visibleGeneratedCount,
329
+ hiddenEffectVerifiedCount: mutationAudit.filter(
330
+ (entry) => entry.action === "remove" && entry.effectVerified,
331
+ ).length,
332
+ networkRequired,
333
+ customJsDeferred: artifacts.customJs.trim().length > 0,
334
+ ...(failed.length
335
+ ? {
336
+ error: "One or more mutation selectors did not match the live page.",
337
+ repairHint: "Inspect the current browser snapshot, choose a selector that exists, and reapply the draft.",
338
+ }
339
+ : {}),
340
+ };
341
+ }
342
+
343
+ saveNow(): Promise<void> {
344
+ this.cancelSave();
345
+ if (!this.state.variation || this.state.saveState === "conflict") {
346
+ return Promise.resolve();
347
+ }
348
+ const variationId = this.state.variation.id;
349
+ const variationRequest = this.variationRequest;
350
+ const queued = this.saveQueue.then(() =>
351
+ this.performSave(variationId, variationRequest),
352
+ );
353
+ this.saveQueue = queued.catch(() => undefined);
354
+ return queued;
355
+ }
356
+
357
+ private async performSave(
358
+ variationId: string,
359
+ saveVariationRequest: number,
360
+ ): Promise<void> {
361
+ if (
362
+ saveVariationRequest !== this.variationRequest ||
363
+ this.state.variation?.id !== variationId ||
364
+ this.state.saveState === "conflict"
365
+ ) {
366
+ return;
367
+ }
368
+ const expectedHash = this.state.variation.artifactHash;
369
+ const snapshot = cloneArtifacts(this.state.artifacts);
370
+ const saveRevision = this.revision;
371
+ this.patch({ saveState: "saving", saveMessage: "Saving..." });
372
+ try {
373
+ const result = await this.api.saveArtifacts(variationId, snapshot, expectedHash);
374
+ if (saveVariationRequest !== this.variationRequest || this.state.variation?.id !== variationId) return;
375
+ const changedDuringSave = saveRevision !== this.revision;
376
+ this.state = {
377
+ ...this.state,
378
+ variation: {
379
+ ...this.state.variation!,
380
+ artifactHash: result.artifactHash,
381
+ reviewStatus: result.reviewStatus,
382
+ },
383
+ artifacts: changedDuringSave ? this.state.artifacts : cloneArtifacts(result.artifacts),
384
+ saveState: changedDuringSave ? "idle" : "saved",
385
+ saveMessage: changedDuringSave
386
+ ? "Saving latest changes..."
387
+ : result.warningIssues[0]?.message ?? "Saved",
388
+ };
389
+ this.emit();
390
+ } catch (error) {
391
+ if (saveVariationRequest !== this.variationRequest || this.state.variation?.id !== variationId) return;
392
+ if (error instanceof EditorApiError && error.status === 409) {
393
+ this.patch({
394
+ saveState: "conflict",
395
+ saveMessage: "Draft changed elsewhere. Reload this variation before saving.",
396
+ });
397
+ } else {
398
+ this.patch({
399
+ saveState: "error",
400
+ saveMessage: error instanceof Error ? error.message : "Draft could not be saved",
401
+ });
402
+ }
403
+ }
404
+ }
405
+
406
+ async exit(): Promise<void> {
407
+ this.cancelSave();
408
+ this.runCleanups();
409
+ this.devicePreview?.close(false);
410
+ this.visualContext?.rollback();
411
+ document.getElementById(EDITOR_CSS_ID)?.remove();
412
+ try {
413
+ window.opener?.postMessage(
414
+ {
415
+ type: "apex:editor-exited",
416
+ sid: this.session.sid,
417
+ experimentId: this.session.experimentId,
418
+ variationId: this.state.variation?.id ?? this.session.initialVariationId,
419
+ },
420
+ this.apiOrigin,
421
+ );
422
+ } catch {}
423
+ try {
424
+ await this.withTimeout(this.api.revoke(), EXIT_REVOKE_TIMEOUT_MS);
425
+ } catch {}
426
+ this.reload();
427
+ }
428
+
429
+ destroy(): void {
430
+ this.cancelSave();
431
+ this.runCleanups();
432
+ this.devicePreview?.close(false);
433
+ this.visualContext?.rollback();
434
+ document.getElementById(EDITOR_CSS_ID)?.remove();
435
+ this.listeners.clear();
436
+ }
437
+
438
+ private mutateSelected(input: Omit<Mutation, "selector">): void {
439
+ const selector = this.state.selectedSelector;
440
+ if (!selector) return;
441
+ this.commit(upsertMutation(this.state.artifacts, { ...input, selector } as Mutation));
442
+ }
443
+
444
+ private commit(next: EditorArtifacts, applyVisual = true): void {
445
+ if (this.state.saveState === "conflict") return;
446
+ this.history.push(this.state.artifacts);
447
+ this.revision += 1;
448
+ this.state = {
449
+ ...this.state,
450
+ artifacts: cloneArtifacts(next),
451
+ saveState: "idle",
452
+ saveMessage: "Unsaved changes",
453
+ canUndo: this.history.canUndo,
454
+ canRedo: this.history.canRedo,
455
+ };
456
+ if (applyVisual) this.applyVisualArtifacts();
457
+ this.devicePreview?.update(this.state.artifacts, this.state.artifactsEnabled);
458
+ this.emit();
459
+ this.scheduleSave();
460
+ }
461
+
462
+ private replaceFromHistory(artifacts: EditorArtifacts): void {
463
+ this.revision += 1;
464
+ this.state = {
465
+ ...this.state,
466
+ artifacts,
467
+ saveState: "idle",
468
+ saveMessage: "Unsaved changes",
469
+ canUndo: this.history.canUndo,
470
+ canRedo: this.history.canRedo,
471
+ jsApplied: false,
472
+ };
473
+ this.applyVisualArtifacts();
474
+ this.devicePreview?.update(artifacts, this.state.artifactsEnabled);
475
+ this.emit();
476
+ this.scheduleSave();
477
+ }
478
+
479
+ private applyVisualArtifacts(): void {
480
+ this.visualContext?.rollback();
481
+ this.visualContext = null;
482
+ document.getElementById(EDITOR_CSS_ID)?.remove();
483
+ if (!this.state.artifactsEnabled) return;
484
+ if (this.state.artifacts.customCss.trim()) {
485
+ const style = document.createElement("style");
486
+ style.id = EDITOR_CSS_ID;
487
+ style.textContent = this.state.artifacts.customCss;
488
+ (document.head || document.documentElement).appendChild(style);
489
+ }
490
+ this.visualContext = createMutationApplicationContext();
491
+ this.visualContext.apply(this.state.artifacts.mutations);
492
+ }
493
+
494
+ private scheduleSave(): void {
495
+ this.cancelSave();
496
+ this.saveTimer = setTimeout(() => void this.saveNow(), AUTOSAVE_MS);
497
+ }
498
+
499
+ private cancelSave(): void {
500
+ if (this.saveTimer) clearTimeout(this.saveTimer);
501
+ this.saveTimer = null;
502
+ }
503
+
504
+ private patch(patch: Partial<EditorState>): void {
505
+ this.state = { ...this.state, ...patch };
506
+ this.emit();
507
+ }
508
+
509
+ private emit(): void {
510
+ for (const listener of this.listeners) listener(this.state);
511
+ }
512
+
513
+ private selectorElements(selector: string): Element[] {
514
+ try {
515
+ return Array.from(document.querySelectorAll(selector));
516
+ } catch {
517
+ return [];
518
+ }
519
+ }
520
+
521
+ private selectorCount(selector: string): number {
522
+ try {
523
+ return document.querySelectorAll(selector).length;
524
+ } catch {
525
+ return -1;
526
+ }
527
+ }
528
+
529
+ private isVisible(element: Element): boolean {
530
+ if (!(element instanceof HTMLElement)) return true;
531
+ const style = getComputedStyle(element);
532
+ if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false;
533
+ const rect = element.getBoundingClientRect();
534
+ return rect.width > 0 && rect.height > 0 || Boolean(element.textContent?.trim());
535
+ }
536
+
537
+ private verifyMutationEffect(
538
+ mutation: Mutation,
539
+ beforeCount: number,
540
+ afterCount: number,
541
+ ): boolean {
542
+ if (beforeCount <= 0) return false;
543
+ if (mutation.action === "remove") return afterCount === 0;
544
+ const elements = this.selectorElements(mutation.selector);
545
+ if (!elements.length) return false;
546
+ if ((mutation.action === "text" || mutation.action === "setText") && typeof mutation.text === "string") {
547
+ return elements.every((element) => element.textContent === mutation.text);
548
+ }
549
+ if (mutation.action === "html" && typeof mutation.html === "string") {
550
+ const sanitizedHtml = sanitizeHtml(mutation.html);
551
+ return elements.every((element) => {
552
+ const expected = element.cloneNode(false) as Element;
553
+ expected.innerHTML = sanitizedHtml;
554
+ return element.innerHTML === expected.innerHTML;
555
+ });
556
+ }
557
+ if (mutation.action === "attribute") {
558
+ const attributes = {
559
+ ...(mutation.attribute ? { [mutation.attribute.name]: mutation.attribute.value } : {}),
560
+ ...(mutation.attributes ?? {}),
561
+ };
562
+ return elements.every((element) => Object.entries(attributes).every(
563
+ ([name, value]) => value === null
564
+ ? !element.hasAttribute(name)
565
+ : element.getAttribute(name) === value,
566
+ ));
567
+ }
568
+ if (mutation.action === "style" || mutation.action === "setStyle") {
569
+ return elements.every((element) => Object.entries(mutation.styles ?? {}).every(
570
+ ([name, value]) => (element as HTMLElement).style.getPropertyValue(
571
+ name.includes("-") ? name : name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`),
572
+ ) === value,
573
+ ));
574
+ }
575
+ return true;
576
+ }
577
+
578
+ private runCleanups(): void {
579
+ for (const cleanup of this.cleanups) cleanup();
580
+ this.cleanups.clear();
581
+ }
582
+
583
+ private async withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
584
+ let timer: ReturnType<typeof setTimeout> | null = null;
585
+ try {
586
+ return await Promise.race([
587
+ promise,
588
+ new Promise<undefined>((resolve) => {
589
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
590
+ }),
591
+ ]);
592
+ } finally {
593
+ if (timer) clearTimeout(timer);
594
+ }
595
+ }
596
+ }
@@ -0,0 +1,60 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { DevicePreview } from "./device-preview";
4
+
5
+ describe("DevicePreview", () => {
6
+ beforeEach(() => {
7
+ document.body.innerHTML = `<main id="store"><h1>Original</h1></main><div id="apex-editor-host"></div>`;
8
+ history.replaceState(null, "", "/products/one");
9
+ });
10
+
11
+ it("applies the visual draft in a 390px frame and restores the store on close", () => {
12
+ const frameDocument = document.implementation.createHTMLDocument("Device");
13
+ frameDocument.body.innerHTML = `<h1>Original</h1>`;
14
+ const frame = {
15
+ hidden: true,
16
+ src: "",
17
+ onload: null as (() => void) | null,
18
+ onerror: null as (() => void) | null,
19
+ contentDocument: frameDocument,
20
+ contentWindow: { location: { href: "http://localhost:3000/products/one" } },
21
+ removeAttribute: vi.fn(),
22
+ } as unknown as HTMLIFrameElement;
23
+ const onState = vi.fn();
24
+ const preview = new DevicePreview(frame, onState);
25
+ preview.open({
26
+ mutations: [{ selector: "h1", action: "setText", text: "Device draft" }],
27
+ customCss: "h1{color:red}",
28
+ customJs: "alert('not executed')",
29
+ });
30
+ expect((document.getElementById("store") as HTMLElement).style.visibility).toBe("hidden");
31
+ frame.onload?.(new Event("load"));
32
+ expect(frameDocument.querySelector("h1")?.textContent).toBe("Device draft");
33
+ expect(frameDocument.getElementById("apex-editor-device-css")?.textContent).toContain("color:red");
34
+ expect(onState).toHaveBeenCalledWith("active");
35
+
36
+ preview.close();
37
+ expect((document.getElementById("store") as HTMLElement).style.visibility).toBe("");
38
+ expect(frame.hidden).toBe(true);
39
+ });
40
+
41
+ it("reports inaccessible frames as blocked", () => {
42
+ const frame = {
43
+ hidden: true,
44
+ src: "",
45
+ onload: null,
46
+ onerror: null as (() => void) | null,
47
+ contentDocument: null,
48
+ contentWindow: null,
49
+ removeAttribute: vi.fn(),
50
+ } as unknown as HTMLIFrameElement;
51
+ const onState = vi.fn();
52
+ const preview = new DevicePreview(frame, onState);
53
+ preview.open({ mutations: [], customCss: "", customJs: "" });
54
+ expect((document.getElementById("store") as HTMLElement).style.visibility).toBe("hidden");
55
+ frame.onerror?.(new Event("error"));
56
+ expect(onState).toHaveBeenLastCalledWith("blocked");
57
+ expect((document.getElementById("store") as HTMLElement).style.visibility).toBe("");
58
+ preview.close();
59
+ });
60
+ });