@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,120 @@
1
+ import { createMutationApplicationContext, type MutationApplicationContext } from "../mutate";
2
+ import type { EditorArtifacts, EditorDeviceState } from "./types";
3
+
4
+ const FRAME_TIMEOUT_MS = 5_000;
5
+ const DEVICE_STYLE_ID = "apex-editor-device-css";
6
+
7
+ export type DevicePreviewSize = { width: number; height: number };
8
+
9
+ export class DevicePreview {
10
+ private context: MutationApplicationContext | null = null;
11
+ private hiddenChildren: Array<{ element: HTMLElement; visibility: string }> = [];
12
+ private timer: ReturnType<typeof setTimeout> | null = null;
13
+ private artifacts: EditorArtifacts | null = null;
14
+
15
+ constructor(
16
+ private readonly frame: HTMLIFrameElement,
17
+ private readonly onState: (state: EditorDeviceState) => void,
18
+ ) {}
19
+
20
+ open(artifacts: EditorArtifacts): void {
21
+ this.close(false);
22
+ this.artifacts = artifacts;
23
+ this.hideStorePage();
24
+ this.onState("loading");
25
+ this.frame.hidden = false;
26
+ this.frame.onload = () => this.handleLoad();
27
+ this.frame.onerror = () => this.blocked();
28
+ const url = new URL(location.href);
29
+ url.hash = "";
30
+ this.frame.src = url.toString();
31
+ this.timer = setTimeout(() => this.blocked(), FRAME_TIMEOUT_MS);
32
+ }
33
+
34
+ update(artifacts: EditorArtifacts, enabled = true): void {
35
+ this.artifacts = enabled ? artifacts : { mutations: [], customCss: "", customJs: "" };
36
+ const doc = this.readDocument();
37
+ if (doc) this.apply(doc, this.artifacts);
38
+ }
39
+
40
+ close(notify = true): void {
41
+ if (this.timer) clearTimeout(this.timer);
42
+ this.timer = null;
43
+ this.context?.rollback();
44
+ this.context = null;
45
+ this.frame.onload = null;
46
+ this.frame.onerror = null;
47
+ this.frame.hidden = true;
48
+ this.frame.removeAttribute("src");
49
+ for (const item of this.hiddenChildren) {
50
+ item.element.style.visibility = item.visibility;
51
+ }
52
+ this.hiddenChildren = [];
53
+ if (notify) this.onState("closed");
54
+ }
55
+
56
+ private handleLoad(): void {
57
+ const doc = this.readDocument();
58
+ if (!doc?.body || !this.artifacts) {
59
+ this.blocked();
60
+ return;
61
+ }
62
+ if (this.timer) clearTimeout(this.timer);
63
+ this.timer = null;
64
+ this.apply(doc, this.artifacts);
65
+ this.onState("active");
66
+ }
67
+
68
+ private readDocument(): Document | null {
69
+ try {
70
+ const doc = this.frame.contentDocument;
71
+ const frameUrl = this.frame.contentWindow?.location.href ?? "";
72
+ if (!doc || !frameUrl || frameUrl === "about:blank") return null;
73
+ return doc;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ private apply(doc: Document, artifacts: EditorArtifacts): void {
80
+ this.context?.rollback();
81
+ this.context = createMutationApplicationContext();
82
+ doc.getElementById(DEVICE_STYLE_ID)?.remove();
83
+ if (artifacts.customCss.trim()) {
84
+ const style = doc.createElement("style");
85
+ style.id = DEVICE_STYLE_ID;
86
+ style.textContent = artifacts.customCss;
87
+ (doc.head || doc.documentElement).appendChild(style);
88
+ }
89
+ this.context.apply(artifacts.mutations, { root: doc });
90
+ }
91
+
92
+ private hideStorePage(): void {
93
+ const editorHost = document.getElementById("apex-editor-host");
94
+ this.hiddenChildren = Array.from(document.body?.children ?? [])
95
+ .filter((element): element is HTMLElement => element instanceof HTMLElement && element !== editorHost)
96
+ .map((element) => {
97
+ const visibility = element.style.visibility;
98
+ element.style.visibility = "hidden";
99
+ return { element, visibility };
100
+ });
101
+ }
102
+
103
+ private blocked(): void {
104
+ if (this.timer) clearTimeout(this.timer);
105
+ this.timer = null;
106
+ this.frame.onload = null;
107
+ this.frame.onerror = null;
108
+ this.frame.hidden = true;
109
+ this.frame.removeAttribute("src");
110
+ this.restoreStorePage();
111
+ this.onState("blocked");
112
+ }
113
+
114
+ private restoreStorePage(): void {
115
+ for (const item of this.hiddenChildren) {
116
+ item.element.style.visibility = item.visibility;
117
+ }
118
+ this.hiddenChildren = [];
119
+ }
120
+ }
@@ -0,0 +1,161 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { __test, getActiveEditorSession, start } from "./index";
3
+
4
+ const controllerMocks = vi.hoisted(() => ({
5
+ initialize: vi.fn(async () => undefined),
6
+ addCleanup: vi.fn(() => vi.fn()),
7
+ destroy: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("./controller", () => ({
11
+ EditorController: class EditorController {
12
+ session = {
13
+ targetOrigin: "https://shop.example",
14
+ };
15
+ initialize = controllerMocks.initialize;
16
+ addCleanup = controllerMocks.addCleanup;
17
+ destroy = controllerMocks.destroy;
18
+ },
19
+ }));
20
+ vi.mock("./view", () => ({
21
+ mountEditorView: vi.fn(() => ({ destroy: vi.fn() })),
22
+ }));
23
+
24
+ function installDocumentStub() {
25
+ const elements = new Map<string, Record<string, unknown>>();
26
+ const appendChild = vi.fn((element: Record<string, unknown>) => {
27
+ if (typeof element.id === "string" && element.id) elements.set(element.id, element);
28
+ return element;
29
+ });
30
+ const documentStub = {
31
+ body: { appendChild },
32
+ documentElement: { appendChild },
33
+ getElementById: vi.fn((id: string) => elements.get(id) ?? null),
34
+ createElement: vi.fn((tag: string) => {
35
+ const element: Record<string, unknown> = {
36
+ id: "",
37
+ remove: vi.fn(() => {
38
+ if (typeof element.id === "string") elements.delete(element.id);
39
+ }),
40
+ setAttribute: vi.fn(),
41
+ };
42
+ if (tag === "div") {
43
+ element.attachShadow = vi.fn(() => ({ appendChild: vi.fn() }));
44
+ }
45
+ return element;
46
+ }),
47
+ };
48
+ vi.stubGlobal("document", documentStub);
49
+ vi.stubGlobal("location", { origin: "https://shop.example" });
50
+ const postMessage = vi.fn();
51
+ vi.stubGlobal("window", { opener: { postMessage } });
52
+ return { appendChild, documentStub, postMessage };
53
+ }
54
+
55
+ const launch = {
56
+ token: "payload.signature",
57
+ sid: "editor-session-1",
58
+ apiOrigin: "https://app.drip-apex.com",
59
+ };
60
+
61
+ beforeEach(() => {
62
+ controllerMocks.initialize.mockReset().mockResolvedValue(undefined);
63
+ controllerMocks.addCleanup.mockClear();
64
+ controllerMocks.destroy.mockClear();
65
+ });
66
+
67
+ afterEach(() => {
68
+ try {
69
+ __test.reset();
70
+ } catch {}
71
+ vi.unstubAllGlobals();
72
+ vi.restoreAllMocks();
73
+ });
74
+
75
+ describe("editor session exchange", () => {
76
+ it("mounts only after a successful scoped session exchange", async () => {
77
+ const { documentStub, postMessage } = installDocumentStub();
78
+ const expiresAt = new Date(Date.now() + 60_000).toISOString();
79
+ const fetchMock = vi.fn(async () => ({
80
+ ok: true,
81
+ json: async () => ({
82
+ ok: true,
83
+ session: {
84
+ sid: launch.sid,
85
+ bearer: "editor-bearer",
86
+ expiresAt,
87
+ shopId: "shop-1",
88
+ experimentId: "experiment-1",
89
+ initialVariationId: "variation-1",
90
+ targetUrl: "https://shop.example/products/one",
91
+ targetOrigin: "https://shop.example",
92
+ variations: [{ id: "variation-1", name: "Treatment" }],
93
+ },
94
+ }),
95
+ }));
96
+ vi.stubGlobal("fetch", fetchMock);
97
+
98
+ await expect(start(launch)).resolves.toBe(true);
99
+ expect(fetchMock).toHaveBeenCalledWith(
100
+ "https://app.drip-apex.com/api/editor-sessions/exchange?sid=editor-session-1",
101
+ expect.objectContaining({
102
+ method: "POST",
103
+ body: JSON.stringify({ token: launch.token }),
104
+ credentials: "omit",
105
+ }),
106
+ );
107
+ expect(documentStub.getElementById).toHaveBeenCalledWith("apex-editor-host");
108
+ expect(getActiveEditorSession()).toMatchObject({
109
+ sid: launch.sid,
110
+ bearer: "editor-bearer",
111
+ initialVariationId: "variation-1",
112
+ });
113
+ expect(postMessage).toHaveBeenCalledWith(
114
+ {
115
+ type: "apex:editor-connected",
116
+ sid: launch.sid,
117
+ experimentId: "experiment-1",
118
+ variationId: "variation-1",
119
+ },
120
+ "https://app.drip-apex.com",
121
+ );
122
+ });
123
+
124
+ it("does not mount when exchange is rejected", async () => {
125
+ const { appendChild } = installDocumentStub();
126
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false })));
127
+
128
+ await expect(start(launch)).resolves.toBe(false);
129
+ expect(appendChild).not.toHaveBeenCalled();
130
+ expect(getActiveEditorSession()).toBeNull();
131
+ });
132
+
133
+ it("cleans the host and scoped state when startup fails after mounting", async () => {
134
+ const { documentStub } = installDocumentStub();
135
+ const expiresAt = new Date(Date.now() + 60_000).toISOString();
136
+ vi.stubGlobal("fetch", vi.fn(async () => ({
137
+ ok: true,
138
+ json: async () => ({
139
+ ok: true,
140
+ session: {
141
+ sid: launch.sid,
142
+ bearer: "editor-bearer",
143
+ expiresAt,
144
+ shopId: "shop-1",
145
+ experimentId: "experiment-1",
146
+ initialVariationId: "variation-1",
147
+ targetUrl: "https://shop.example/products/one",
148
+ targetOrigin: "https://shop.example",
149
+ variations: [{ id: "variation-1", name: "Treatment" }],
150
+ },
151
+ }),
152
+ })));
153
+ controllerMocks.initialize.mockRejectedValueOnce(new Error("load failed"));
154
+
155
+ await expect(start(launch)).resolves.toBe(false);
156
+
157
+ expect(getActiveEditorSession()).toBeNull();
158
+ expect(documentStub.getElementById("apex-editor-host")).toBeNull();
159
+ expect(controllerMocks.destroy).toHaveBeenCalledOnce();
160
+ });
161
+ });
@@ -0,0 +1,169 @@
1
+ import type { EditorLaunchHint } from "../editor-bootstrap";
2
+ import type { ActiveEditorSession } from "./types";
3
+ import { EditorApi } from "./api";
4
+ import { EditorCommandExecutor, EditorCommandPoller } from "./command-runtime";
5
+ import { EditorController } from "./controller";
6
+ import { mountEditorView, type EditorView } from "./view";
7
+
8
+ const EDITOR_HOST_ID = "apex-editor-host";
9
+ const EXCHANGE_TIMEOUT_MS = 10_000;
10
+
11
+ export type { ActiveEditorSession } from "./types";
12
+
13
+ let activeSession: ActiveEditorSession | null = null;
14
+ let activeController: EditorController | null = null;
15
+ let activeView: EditorView | null = null;
16
+ let activeCommandPoller: EditorCommandPoller | null = null;
17
+
18
+ function cleanupActiveEditor(): void {
19
+ activeCommandPoller?.stop();
20
+ activeView?.destroy();
21
+ activeController?.destroy();
22
+ activeView = null;
23
+ activeCommandPoller = null;
24
+ activeController = null;
25
+ activeSession = null;
26
+ document.getElementById(EDITOR_HOST_ID)?.remove();
27
+ }
28
+
29
+ function readString(value: unknown): string | null {
30
+ return typeof value === "string" && value.trim() ? value.trim() : null;
31
+ }
32
+
33
+ function parseSession(value: unknown, sid: string): ActiveEditorSession | null {
34
+ if (!value || typeof value !== "object") return null;
35
+ const record = value as Record<string, unknown>;
36
+ const parsed = {
37
+ sid: readString(record.sid),
38
+ bearer: readString(record.bearer),
39
+ expiresAt: readString(record.expiresAt),
40
+ shopId: readString(record.shopId),
41
+ experimentId: readString(record.experimentId),
42
+ initialVariationId: readString(record.initialVariationId),
43
+ targetUrl: readString(record.targetUrl),
44
+ targetOrigin: readString(record.targetOrigin),
45
+ };
46
+ if (
47
+ parsed.sid !== sid ||
48
+ !parsed.bearer ||
49
+ !parsed.expiresAt ||
50
+ !parsed.shopId ||
51
+ !parsed.experimentId ||
52
+ !parsed.initialVariationId ||
53
+ !parsed.targetUrl ||
54
+ !parsed.targetOrigin ||
55
+ !Number.isFinite(Date.parse(parsed.expiresAt)) ||
56
+ Date.parse(parsed.expiresAt) <= Date.now()
57
+ ) {
58
+ return null;
59
+ }
60
+ const variations: ActiveEditorSession["variations"] = [];
61
+ if (Array.isArray(record.variations)) {
62
+ for (const item of record.variations) {
63
+ if (!item || typeof item !== "object") continue;
64
+ const variation = item as Record<string, unknown>;
65
+ const id = readString(variation.id);
66
+ if (id) {
67
+ variations.push({
68
+ id,
69
+ name: readString(variation.name),
70
+ slug: readString(variation.slug),
71
+ });
72
+ }
73
+ }
74
+ }
75
+ return {
76
+ sid,
77
+ bearer: parsed.bearer,
78
+ expiresAt: parsed.expiresAt,
79
+ shopId: parsed.shopId,
80
+ experimentId: parsed.experimentId,
81
+ experimentName: readString(record.experimentName),
82
+ initialVariationId: parsed.initialVariationId,
83
+ targetUrl: parsed.targetUrl,
84
+ targetOrigin: parsed.targetOrigin,
85
+ variations,
86
+ };
87
+ }
88
+
89
+ function mountEditorHost(controller: EditorController): void {
90
+ document.getElementById(EDITOR_HOST_ID)?.remove();
91
+ const host = document.createElement("div");
92
+ host.id = EDITOR_HOST_ID;
93
+ host.setAttribute("data-apex-editor-state", "connected");
94
+ const shadow = host.attachShadow({ mode: "closed" });
95
+ (document.body || document.documentElement).appendChild(host);
96
+ activeView = mountEditorView(shadow, controller);
97
+ }
98
+
99
+ export async function start(launch: EditorLaunchHint): Promise<boolean> {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
102
+ let ownsActiveEditor = false;
103
+ try {
104
+ const exchangeUrl = new URL("/api/editor-sessions/exchange", launch.apiOrigin);
105
+ exchangeUrl.searchParams.set("sid", launch.sid);
106
+ const response = await fetch(exchangeUrl.toString(), {
107
+ method: "POST",
108
+ headers: { "Content-Type": "application/json" },
109
+ body: JSON.stringify({ token: launch.token }),
110
+ cache: "no-store",
111
+ credentials: "omit",
112
+ referrerPolicy: "no-referrer",
113
+ signal: controller.signal,
114
+ });
115
+ if (!response.ok) return false;
116
+ const payload = (await response.json().catch(() => null)) as
117
+ | { ok?: unknown; session?: unknown }
118
+ | null;
119
+ if (payload?.ok !== true) return false;
120
+ const session = parseSession(payload.session, launch.sid);
121
+ if (!session) return false;
122
+ if (
123
+ typeof location !== "undefined" &&
124
+ session.targetOrigin !== location.origin
125
+ ) {
126
+ return false;
127
+ }
128
+ activeSession = session;
129
+ ownsActiveEditor = true;
130
+ const api = new EditorApi(launch.apiOrigin, session);
131
+ activeController = new EditorController(session, launch.apiOrigin, api);
132
+ mountEditorHost(activeController);
133
+ await activeController.initialize();
134
+ activeCommandPoller = new EditorCommandPoller(
135
+ api,
136
+ new EditorCommandExecutor(activeController),
137
+ );
138
+ activeController.addCleanup(() => {
139
+ activeCommandPoller?.stop();
140
+ activeCommandPoller = null;
141
+ });
142
+ activeCommandPoller.start();
143
+ try {
144
+ window.opener?.postMessage(
145
+ {
146
+ type: "apex:editor-connected",
147
+ sid: session.sid,
148
+ experimentId: session.experimentId,
149
+ variationId: session.initialVariationId,
150
+ },
151
+ launch.apiOrigin,
152
+ );
153
+ } catch {}
154
+ return true;
155
+ } catch {
156
+ if (ownsActiveEditor) cleanupActiveEditor();
157
+ return false;
158
+ } finally {
159
+ clearTimeout(timeout);
160
+ }
161
+ }
162
+
163
+ export function getActiveEditorSession(): ActiveEditorSession | null {
164
+ return activeSession;
165
+ }
166
+
167
+ export const __test = {
168
+ reset: cleanupActiveEditor,
169
+ };
@@ -0,0 +1,148 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ collectEditorDomContext,
5
+ mergeEditorOperatorArtifacts,
6
+ runEditorOperatorTurn,
7
+ } from "./operator";
8
+ import type { ActiveEditorSession } from "./types";
9
+
10
+ const session: ActiveEditorSession = {
11
+ sid: "session_1",
12
+ bearer: "secret_bearer",
13
+ expiresAt: "2099-01-01T00:00:00.000Z",
14
+ shopId: "shop_1",
15
+ experimentId: "experiment_1",
16
+ initialVariationId: "variation_1",
17
+ targetUrl: "https://shop.test/products/one?preview_token=secret#private",
18
+ targetOrigin: "https://shop.test",
19
+ variations: [{ id: "variation_1", name: "Treatment" }],
20
+ };
21
+
22
+ describe("editor operator client", () => {
23
+ beforeEach(() => {
24
+ document.title = "Product one";
25
+ document.body.innerHTML = `
26
+ <main id="product"><h1>Original title</h1>
27
+ <input name="email" value="customer@example.com">
28
+ <a href="/account?token=secret-link#private">Account</a>
29
+ <img src="https://cdn.test/product.jpg?signature=secret-image" alt="Product">
30
+ <button data-testid="buy">Add to cart</button></main>`;
31
+ });
32
+
33
+ it("collects structural page context without form values", () => {
34
+ const context = collectEditorDomContext();
35
+ expect(context).toContain("Original title");
36
+ expect(context).toContain('data-testid="buy"');
37
+ expect(context).not.toContain("customer@example.com");
38
+ expect(context).not.toContain("secret-link");
39
+ expect(context).not.toContain("secret-image");
40
+ expect(context).toContain('href="http://localhost:3000/account"');
41
+ expect(context).toContain('src="https://cdn.test/product.jpg"');
42
+ });
43
+
44
+ it("upserts append results and replaces complete artifact sets", () => {
45
+ const current = {
46
+ mutations: [
47
+ { selector: "h1", action: "setText" as const, text: "First" },
48
+ ],
49
+ customCss: "h1 { color: red; }",
50
+ customJs: "",
51
+ };
52
+ const appended = mergeEditorOperatorArtifacts({
53
+ current,
54
+ generatedMutations: [
55
+ { selector: "h1", action: "setText", text: "Second" },
56
+ ],
57
+ generatedCss: ".new { display: block; }",
58
+ generatedJs: "",
59
+ mergeStrategy: "append",
60
+ });
61
+ expect(appended.mutations).toEqual([
62
+ { selector: "h1", action: "setText", text: "Second" },
63
+ ]);
64
+ expect(appended.customCss).toContain("h1 { color: red; }");
65
+ expect(appended.customCss).toContain(".new { display: block; }");
66
+
67
+ expect(
68
+ mergeEditorOperatorArtifacts({
69
+ current,
70
+ generatedMutations: [
71
+ { selector: "button", action: "setText", text: "Buy now" },
72
+ ],
73
+ generatedCss: "",
74
+ generatedJs: "",
75
+ mergeStrategy: "replace",
76
+ }).mutations,
77
+ ).toEqual([{ selector: "button", action: "setText", text: "Buy now" }]);
78
+ });
79
+
80
+ it("sends the scoped bearer and returns merged artifacts", async () => {
81
+ const fetchImpl = vi.fn(async (_url: URL, init?: RequestInit) =>
82
+ Response.json({
83
+ mutations: [
84
+ { selector: "h1", action: "setText", text: "Generated title" },
85
+ ],
86
+ customCss: "h1 { color: blue; }",
87
+ customJs: "",
88
+ mergeStrategy: "append",
89
+ explanation: "Updated the heading.",
90
+ }),
91
+ ) as unknown as typeof fetch;
92
+ const result = await runEditorOperatorTurn({
93
+ apiOrigin: "https://app.test",
94
+ session,
95
+ variationId: "variation_1",
96
+ prompt: "Make the title clearer",
97
+ artifacts: { mutations: [], customCss: "", customJs: "" },
98
+ conversation: [],
99
+ selectedElement: {
100
+ selector: "button[data-testid=buy]",
101
+ tag: "button",
102
+ textPreview: "Add to cart",
103
+ alternatives: ["#product button"],
104
+ },
105
+ fetchImpl,
106
+ });
107
+ expect(result.artifactCount).toBe(2);
108
+ expect(result.explanation).toBe("Updated the heading.");
109
+ const [url, init] = fetchImpl.mock.calls[0];
110
+ expect(String(url)).toBe(
111
+ "https://app.test/api/editor-sessions/session_1/operator",
112
+ );
113
+ expect(new Headers(init?.headers).get("Authorization")).toBe(
114
+ "Bearer secret_bearer",
115
+ );
116
+ const requestBody = JSON.parse(String(init?.body));
117
+ expect(requestBody).toMatchObject({
118
+ prompt: "Make the title clearer",
119
+ variationId: "variation_1",
120
+ existingMutations: [],
121
+ pageContext: "Live merchant page: https://shop.test/products/one",
122
+ selectedElement: {
123
+ selector: "button[data-testid=buy]",
124
+ tag: "button",
125
+ textPreview: "Add to cart",
126
+ alternatives: ["#product button"],
127
+ },
128
+ });
129
+ expect(String(init?.body)).not.toContain("preview_token");
130
+ expect(String(init?.body)).not.toContain("secret");
131
+ });
132
+
133
+ it("surfaces API failures", async () => {
134
+ await expect(
135
+ runEditorOperatorTurn({
136
+ apiOrigin: "https://app.test",
137
+ session,
138
+ variationId: "variation_1",
139
+ prompt: "Change it",
140
+ artifacts: { mutations: [], customCss: "", customJs: "" },
141
+ conversation: [],
142
+ fetchImpl: vi.fn(async () =>
143
+ Response.json({ error: "Operator is disabled" }, { status: 403 }),
144
+ ) as unknown as typeof fetch,
145
+ }),
146
+ ).rejects.toThrow("Operator is disabled");
147
+ });
148
+ });