@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,535 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { mountEditorView } from "./view";
4
+ import * as operator from "./operator";
5
+ import type { EditorController } from "./controller";
6
+ import type { EditorState } from "./types";
7
+
8
+ function state(overrides: Partial<EditorState> = {}): EditorState {
9
+ return {
10
+ variation: { id: "variation-1", name: "Treatment", artifactHash: "hash", reviewStatus: "approved" },
11
+ artifacts: { mutations: [], customCss: "", customJs: "" },
12
+ artifactsEnabled: true,
13
+ pickerActive: false,
14
+ selectedElement: null,
15
+ selectedSelector: null,
16
+ saveState: "saved",
17
+ saveMessage: "Saved",
18
+ dock: "top",
19
+ canUndo: false,
20
+ canRedo: false,
21
+ jsApplied: false,
22
+ reloadRequired: false,
23
+ deviceState: "closed",
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeController(initial: EditorState = state()) {
29
+ let current = initial;
30
+ const listeners = new Set<(value: EditorState) => void>();
31
+ const controller = {
32
+ apiOrigin: "https://app.test",
33
+ session: {
34
+ sid: "session-1",
35
+ bearer: "bearer",
36
+ targetUrl: "https://shop.test/products/one?preview_token=private#secret",
37
+ targetOrigin: "https://shop.test",
38
+ experimentId: "experiment-1",
39
+ experimentName: "PDP — free returns messaging",
40
+ initialVariationId: "variation-1",
41
+ shopId: "shop-1",
42
+ expiresAt: new Date(Date.now() + 90 * 60 * 1000).toISOString(),
43
+ variations: [
44
+ { id: "variation-1", name: "Treatment" },
45
+ { id: "variation-2", name: "Urgency angle" },
46
+ ],
47
+ },
48
+ getState: () => current,
49
+ subscribe: (next: (value: EditorState) => void) => {
50
+ listeners.add(next);
51
+ next(current);
52
+ return () => listeners.delete(next);
53
+ },
54
+ emit: (next: EditorState) => {
55
+ current = next;
56
+ for (const listener of listeners) listener(current);
57
+ },
58
+ attachDevicePreview: vi.fn(),
59
+ setDeviceState: vi.fn(),
60
+ setPickerActive: vi.fn(),
61
+ selectElement: vi.fn(),
62
+ clearSelection: vi.fn(),
63
+ clearSelectedElement: vi.fn(),
64
+ selectVariation: vi.fn(),
65
+ toggleArtifacts: vi.fn(),
66
+ undo: vi.fn(),
67
+ redo: vi.fn(),
68
+ openDevicePreview: vi.fn(),
69
+ closeDevicePreview: vi.fn(),
70
+ exit: vi.fn(),
71
+ applyCustomJs: vi.fn(),
72
+ setCustomCss: vi.fn(),
73
+ setCustomJs: vi.fn(),
74
+ removeSelector: vi.fn(),
75
+ removeMutation: vi.fn(),
76
+ restoreArtifacts: vi.fn(),
77
+ resolveConflict: vi.fn(),
78
+ extendSession: vi.fn(async () => null),
79
+ applyCommandArtifacts: vi.fn(() => ({ ok: true })),
80
+ saveNow: vi.fn(async () => undefined),
81
+ };
82
+ return controller as typeof controller & EditorController;
83
+ }
84
+
85
+ function mount(controller = makeController()) {
86
+ const host = document.createElement("div");
87
+ document.body.appendChild(host);
88
+ const shadow = host.attachShadow({ mode: "open" });
89
+ const view = mountEditorView(shadow, controller as unknown as EditorController);
90
+ return {
91
+ shadow,
92
+ view,
93
+ controller,
94
+ cleanup() {
95
+ view.destroy();
96
+ host.remove();
97
+ },
98
+ };
99
+ }
100
+
101
+ describe("editor view v2", () => {
102
+ it("renders the operator-first dock with identity, tabs, and calm status chrome", () => {
103
+ const { shadow, cleanup } = mount();
104
+
105
+ expect(shadow.textContent).toContain("PDP — free returns messaging");
106
+ expect(shadow.textContent).toContain("On your store · shop.test");
107
+ expect(shadow.textContent).toContain("Draft");
108
+ expect(shadow.textContent).toContain("What should change on this page?");
109
+ expect(shadow.querySelector('[role="tablist"]')).not.toBeNull();
110
+ expect(shadow.querySelector('[data-tab="operator"]')?.getAttribute("aria-selected")).toBe("true");
111
+ expect(shadow.querySelector('[data-tab="code"]')).not.toBeNull();
112
+ expect(shadow.querySelector('[data-tab="changes"]')).not.toBeNull();
113
+ expect(shadow.querySelector<HTMLButtonElement>('[aria-label="Undo"]')?.disabled).toBe(true);
114
+ // Session status is a quiet footer note, connection chip only when degraded.
115
+ expect(shadow.textContent).toMatch(/Session · \d+ min left/);
116
+ expect(shadow.textContent).not.toContain("Offline");
117
+ expect(shadow.querySelector("style")?.textContent).toContain("prefers-reduced-motion");
118
+ expect(shadow.querySelector<HTMLIFrameElement>("iframe")?.name).toBe("apex-editor-device-preview");
119
+ // No manual visual-property controls anywhere.
120
+ expect(shadow.textContent).not.toContain("Text color");
121
+ expect(shadow.textContent).not.toContain("Hide");
122
+ cleanup();
123
+ });
124
+
125
+ it("shows the boot overlay until the first draft loads", () => {
126
+ const controller = makeController(state({ variation: null, saveState: "idle", saveMessage: "Loading draft..." }));
127
+ const { shadow, cleanup } = mount(controller);
128
+
129
+ expect(shadow.textContent).toContain("Starting a secure editing session");
130
+ expect(shadow.textContent).toContain("Connecting to shop.test");
131
+ expect(shadow.textContent).toContain("shoppers never see your edits");
132
+
133
+ controller.emit(state());
134
+ expect(shadow.querySelector<HTMLElement>(".boot")?.hidden).toBe(true);
135
+ cleanup();
136
+ });
137
+
138
+ it("has a [hidden] display:none override for the boot overlay, since .boot sets display:flex", () => {
139
+ // Regression: `.boot` declares `display:flex`. Author CSS always wins over the
140
+ // UA `[hidden]{display:none}` default, so toggling the `hidden` IDL property
141
+ // alone (asserted above) does not hide the overlay in a real browser unless
142
+ // the stylesheet also carries a `.boot[hidden]` reset — the overlay stayed on
143
+ // screen forever even after `bootDone` flipped true and `variation` loaded.
144
+ const { shadow, cleanup } = mount();
145
+ const styleText = shadow.querySelector("style")?.textContent ?? "";
146
+ expect(styleText).toMatch(/\.boot\[hidden\]\s*\{[^}]*display:\s*none/);
147
+ cleanup();
148
+ });
149
+
150
+ it("renders the conflict modal and routes both resolutions to the controller", () => {
151
+ const controller = makeController();
152
+ const { shadow, cleanup } = mount(controller);
153
+
154
+ controller.emit(state({ saveState: "conflict", saveMessage: "Draft changed elsewhere" }));
155
+ expect(shadow.textContent).toContain("This draft changed somewhere else");
156
+ shadow.querySelector<HTMLButtonElement>('[data-action="conflict-mine"]')?.click();
157
+ expect(controller.resolveConflict).toHaveBeenCalledWith("mine");
158
+ shadow.querySelector<HTMLButtonElement>('[data-action="conflict-theirs"]')?.click();
159
+ expect(controller.resolveConflict).toHaveBeenCalledWith("theirs");
160
+ cleanup();
161
+ });
162
+
163
+ it("runs an Operator turn from the conversation and reports the result card", async () => {
164
+ const generated = {
165
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Generated" }],
166
+ customCss: "",
167
+ customJs: "",
168
+ };
169
+ vi.spyOn(operator, "runEditorOperatorTurn").mockResolvedValueOnce({
170
+ artifacts: generated,
171
+ explanation: "Updated the heading.",
172
+ artifactCount: 1,
173
+ mergeStrategy: "append",
174
+ });
175
+ const selectedButton = document.createElement("button");
176
+ selectedButton.textContent = "Add to cart";
177
+ document.body.appendChild(selectedButton);
178
+ const controller = makeController(state({
179
+ selectedElement: selectedButton,
180
+ selectedSelector: "button.add-to-cart",
181
+ }));
182
+ const { shadow, cleanup } = mount(controller);
183
+
184
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]');
185
+ expect(prompt).not.toBeNull();
186
+ expect(shadow.textContent).toContain("Talking about");
187
+ expect(shadow.textContent).toContain("button");
188
+ prompt!.value = "Make the heading clearer";
189
+ prompt!.dispatchEvent(new Event("input", { bubbles: true }));
190
+ prompt!.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
191
+
192
+ expect(shadow.textContent).toContain("Make the heading clearer");
193
+ await vi.waitFor(() => expect(controller.saveNow).toHaveBeenCalledOnce());
194
+ expect(operator.runEditorOperatorTurn).toHaveBeenCalledWith(
195
+ expect.objectContaining({
196
+ apiOrigin: "https://app.test",
197
+ pageContext: expect.stringContaining(
198
+ "Live merchant page: https://shop.test/products/one",
199
+ ),
200
+ prompt: "Make the heading clearer",
201
+ selectedElement: {
202
+ selector: "button.add-to-cart",
203
+ tag: "button",
204
+ textPreview: "Add to cart",
205
+ },
206
+ variationId: "variation-1",
207
+ }),
208
+ );
209
+ expect(controller.clearSelectedElement).toHaveBeenCalledOnce();
210
+ expect(controller.applyCommandArtifacts).toHaveBeenCalledWith(generated);
211
+ await vi.waitFor(() => expect(shadow.textContent).toContain("it's on the page now"));
212
+ expect(shadow.textContent).toContain("Updated the heading.");
213
+ expect(shadow.textContent).toContain("Undo this");
214
+ cleanup();
215
+ selectedButton.remove();
216
+ });
217
+
218
+ it("shows the failure card with the nothing-changed guarantee", async () => {
219
+ vi.spyOn(operator, "runEditorOperatorTurn").mockRejectedValueOnce(new Error("Model unavailable"));
220
+ const controller = makeController();
221
+ const { shadow, cleanup } = mount(controller);
222
+
223
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]');
224
+ prompt!.value = "Add a countdown";
225
+ prompt!.dispatchEvent(new Event("input", { bubbles: true }));
226
+ prompt!.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
227
+
228
+ await vi.waitFor(() => expect(shadow.textContent).toContain("Operator couldn't finish"));
229
+ expect(shadow.textContent).toContain("Model unavailable");
230
+ expect(shadow.textContent).toContain("Nothing was changed on the draft.");
231
+ expect(shadow.querySelector('[data-action="op-retry"]')).not.toBeNull();
232
+ expect(controller.applyCommandArtifacts).not.toHaveBeenCalled();
233
+ cleanup();
234
+ });
235
+
236
+ it("does not apply a stopped Operator result that resolves late", async () => {
237
+ const generated = {
238
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Late result" }],
239
+ customCss: "",
240
+ customJs: "",
241
+ };
242
+ let resolveTurn!: (value: Awaited<ReturnType<typeof operator.runEditorOperatorTurn>>) => void;
243
+ vi.spyOn(operator, "runEditorOperatorTurn").mockReturnValueOnce(new Promise((resolve) => {
244
+ resolveTurn = resolve;
245
+ }));
246
+ const controller = makeController();
247
+ const { shadow, cleanup } = mount(controller);
248
+
249
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
250
+ prompt.value = "Make a late change";
251
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
252
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
253
+ shadow.querySelector<HTMLButtonElement>('[data-action="op-stop"]')?.click();
254
+ resolveTurn({ artifacts: generated, explanation: "Late", artifactCount: 1, mergeStrategy: "append" });
255
+
256
+ await vi.waitFor(() => expect(shadow.textContent).not.toContain("Make a late change"));
257
+ expect(controller.applyCommandArtifacts).not.toHaveBeenCalled();
258
+ expect(controller.saveNow).not.toHaveBeenCalled();
259
+ cleanup();
260
+ });
261
+
262
+ it("aborts an active Operator run when the editor view is destroyed", async () => {
263
+ let resolveTurn!: (value: Awaited<ReturnType<typeof operator.runEditorOperatorTurn>>) => void;
264
+ vi.spyOn(operator, "runEditorOperatorTurn").mockReturnValueOnce(new Promise((resolve) => {
265
+ resolveTurn = resolve;
266
+ }));
267
+ const controller = makeController();
268
+ const { shadow, view } = mount(controller);
269
+
270
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
271
+ prompt.value = "Make a late teardown change";
272
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
273
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
274
+ const signal = vi.mocked(operator.runEditorOperatorTurn).mock.calls.at(-1)?.[0].signal;
275
+
276
+ view.destroy();
277
+ expect(signal?.aborted).toBe(true);
278
+ resolveTurn({
279
+ artifacts: {
280
+ mutations: [{ selector: "h1", action: "setText", text: "Late result" }],
281
+ customCss: "",
282
+ customJs: "",
283
+ },
284
+ explanation: "Late",
285
+ artifactCount: 1,
286
+ mergeStrategy: "append",
287
+ });
288
+ await Promise.resolve();
289
+ await Promise.resolve();
290
+
291
+ expect(controller.applyCommandArtifacts).not.toHaveBeenCalled();
292
+ expect(controller.saveNow).not.toHaveBeenCalled();
293
+ });
294
+
295
+ it("keeps an applied Operator change visible and reports a save failure honestly", async () => {
296
+ const generated = {
297
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Unsaved result" }],
298
+ customCss: "",
299
+ customJs: "",
300
+ };
301
+ vi.spyOn(operator, "runEditorOperatorTurn").mockResolvedValueOnce({
302
+ artifacts: generated,
303
+ explanation: "Updated the heading.",
304
+ artifactCount: 1,
305
+ mergeStrategy: "append",
306
+ });
307
+ const controller = makeController();
308
+ controller.applyCommandArtifacts.mockImplementationOnce((artifacts) => {
309
+ controller.emit(state({ artifacts, saveState: "idle", saveMessage: "Unsaved changes" }));
310
+ return { ok: true };
311
+ });
312
+ controller.saveNow.mockImplementationOnce(async () => {
313
+ controller.emit({ ...controller.getState(), saveState: "error", saveMessage: "Network unavailable" });
314
+ });
315
+ const { shadow, cleanup } = mount(controller);
316
+
317
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
318
+ prompt.value = "Update the heading";
319
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
320
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
321
+
322
+ await vi.waitFor(() => expect(shadow.textContent).toContain("Change applied, but not saved"));
323
+ expect(shadow.textContent).toContain("remains on this page");
324
+ expect(shadow.textContent).not.toContain("Nothing was changed on the draft");
325
+ expect(controller.getState().artifacts).toEqual(generated);
326
+ cleanup();
327
+ });
328
+
329
+ it("disables run-level undo after a later manual edit changes the artifact", async () => {
330
+ const generated = {
331
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Generated" }],
332
+ customCss: "",
333
+ customJs: "",
334
+ };
335
+ vi.spyOn(operator, "runEditorOperatorTurn").mockResolvedValueOnce({
336
+ artifacts: generated,
337
+ explanation: "Updated the heading.",
338
+ artifactCount: 1,
339
+ mergeStrategy: "append",
340
+ });
341
+ const controller = makeController();
342
+ controller.applyCommandArtifacts.mockImplementationOnce((artifacts) => {
343
+ controller.emit(state({ artifacts, saveState: "idle" }));
344
+ return { ok: true };
345
+ });
346
+ const { shadow, cleanup } = mount(controller);
347
+
348
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
349
+ prompt.value = "Update the heading";
350
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
351
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
352
+
353
+ await vi.waitFor(() => expect(shadow.querySelector<HTMLButtonElement>('[data-action="op-undo-run"]')?.disabled).toBe(false));
354
+ controller.emit(state({ artifacts: { ...generated, customCss: "h1{color:red}" }, saveState: "idle" }));
355
+ expect(shadow.querySelector<HTMLButtonElement>('[data-action="op-undo-run"]')?.disabled).toBe(true);
356
+ cleanup();
357
+ });
358
+
359
+ it("cancels an active Operator run and saves before switching variations", async () => {
360
+ const generated = {
361
+ mutations: [{ selector: "h1", action: "setText" as const, text: "Late result" }],
362
+ customCss: "",
363
+ customJs: "",
364
+ };
365
+ let resolveTurn!: (value: Awaited<ReturnType<typeof operator.runEditorOperatorTurn>>) => void;
366
+ vi.spyOn(operator, "runEditorOperatorTurn").mockReturnValueOnce(new Promise((resolve) => {
367
+ resolveTurn = resolve;
368
+ }));
369
+ const controller = makeController();
370
+ const { shadow, cleanup } = mount(controller);
371
+
372
+ const prompt = shadow.querySelector<HTMLTextAreaElement>('[data-field="operator-prompt"]')!;
373
+ prompt.value = "Change before switching";
374
+ prompt.dispatchEvent(new Event("input", { bubbles: true }));
375
+ prompt.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
376
+ shadow.querySelector<HTMLButtonElement>('[data-action="variation-open"]')?.click();
377
+ shadow.querySelector<HTMLButtonElement>('[data-action="variation-pick"][data-variation="variation-2"]')?.click();
378
+ resolveTurn({ artifacts: generated, explanation: "Late", artifactCount: 1, mergeStrategy: "append" });
379
+
380
+ await vi.waitFor(() => expect(controller.selectVariation).toHaveBeenCalledWith("variation-2"));
381
+ expect(controller.applyCommandArtifacts).not.toHaveBeenCalled();
382
+ expect(controller.saveNow).toHaveBeenCalled();
383
+ expect(controller.toggleArtifacts).toHaveBeenCalledWith(true);
384
+ cleanup();
385
+ });
386
+
387
+ it("lists changes with origins, supports per-change removal, and exposes the artifact JSON", () => {
388
+ const controller = makeController(state({
389
+ artifacts: {
390
+ mutations: [{ selector: "h1.title", action: "setText", text: "New title" }],
391
+ customCss: ".a{color:red}",
392
+ customJs: "",
393
+ },
394
+ }));
395
+ const { shadow, cleanup } = mount(controller);
396
+
397
+ shadow.querySelector<HTMLButtonElement>('[data-tab="changes"]')?.click();
398
+ expect(shadow.textContent).toContain("2 changes in this draft");
399
+ expect(shadow.textContent).toContain("Operator");
400
+ expect(shadow.textContent).toContain("Custom CSS");
401
+
402
+ shadow.querySelector<HTMLButtonElement>('[data-action="chg-remove"][data-kind="mutation"]')?.click();
403
+ expect(controller.removeMutation).toHaveBeenCalledWith(0);
404
+ shadow.querySelector<HTMLButtonElement>('[data-action="chg-remove"][data-kind="css"]')?.click();
405
+ expect(controller.setCustomCss).toHaveBeenCalledWith("");
406
+
407
+ shadow.querySelector<HTMLButtonElement>('[data-action="artifact-toggle"]')?.click();
408
+ expect(shadow.textContent).toContain("New title");
409
+ expect(shadow.textContent).toContain("artifactHash");
410
+ cleanup();
411
+ });
412
+
413
+ it("compares with the original via the segment and shows the viewing-original chip", () => {
414
+ const controller = makeController();
415
+ const { shadow, cleanup } = mount(controller);
416
+
417
+ shadow.querySelector<HTMLButtonElement>('[data-action="compare"][data-view="original"]')?.click();
418
+ expect(controller.toggleArtifacts).toHaveBeenCalledWith(false);
419
+ expect(shadow.textContent).toContain("Viewing the original — draft changes hidden");
420
+
421
+ shadow.querySelector<HTMLButtonElement>('[data-action="show-variation"]')?.click();
422
+ expect(controller.toggleArtifacts).toHaveBeenCalledWith(true);
423
+ expect(shadow.textContent).not.toContain("Viewing the original");
424
+ cleanup();
425
+ });
426
+
427
+ it("detects query-only SPA navigation away from the target", async () => {
428
+ vi.useFakeTimers();
429
+ history.replaceState({}, "", "/products/one?preview_token=private");
430
+ const { shadow, cleanup } = mount();
431
+
432
+ history.replaceState({}, "", "/products/one?preview_token=other");
433
+ await vi.advanceTimersByTimeAsync(1_000);
434
+
435
+ expect(shadow.textContent).toContain(
436
+ "You navigated to /products/one?preview_token=other",
437
+ );
438
+ cleanup();
439
+ history.replaceState({}, "", "/");
440
+ vi.useRealTimers();
441
+ });
442
+
443
+ it("exposes the mobile sheet state on the host for browser QA", () => {
444
+ const originalMatchMedia = window.matchMedia;
445
+ window.matchMedia = vi.fn(() => ({
446
+ matches: true,
447
+ addEventListener: vi.fn(),
448
+ removeEventListener: vi.fn(),
449
+ })) as unknown as typeof window.matchMedia;
450
+ const { shadow, cleanup } = mount();
451
+
452
+ expect((shadow.host as HTMLElement).dataset.apexEditorSheetState).toBe("collapsed");
453
+ shadow.querySelector<HTMLButtonElement>('[data-action="sheet-toggle"]')?.click();
454
+ expect((shadow.host as HTMLElement).dataset.apexEditorSheetState).toBe("expanded");
455
+
456
+ cleanup();
457
+ window.matchMedia = originalMatchMedia;
458
+ });
459
+
460
+ it("keeps the code path independent and honest about JavaScript reversibility", () => {
461
+ const controller = makeController();
462
+ const { shadow, cleanup } = mount(controller);
463
+
464
+ shadow.querySelector<HTMLButtonElement>('[data-tab="code"]')?.click();
465
+ expect(shadow.textContent).toContain("works without Operator");
466
+ const css = shadow.querySelector<HTMLTextAreaElement>('[data-field="css"]');
467
+ css!.value = ".x{display:none}";
468
+ css!.dispatchEvent(new Event("input", { bubbles: true }));
469
+ expect(controller.setCustomCss).toHaveBeenCalledWith(".x{display:none}");
470
+
471
+ shadow.querySelector<HTMLButtonElement>('[data-action="code-lang"][data-lang="js"]')?.click();
472
+ expect(shadow.textContent).toContain("can't be undone by the editor");
473
+ shadow.querySelector<HTMLButtonElement>('[data-action="run-js"]')?.click();
474
+ expect(controller.applyCustomJs).toHaveBeenCalled();
475
+ cleanup();
476
+ });
477
+
478
+ it("opens the review-and-close modal from Done and saves before exiting", async () => {
479
+ const controller = makeController();
480
+ const { shadow, cleanup } = mount(controller);
481
+
482
+ shadow.querySelector<HTMLButtonElement>('[data-action="done"]')?.click();
483
+ expect(shadow.textContent).toContain("Done editing?");
484
+ expect(shadow.textContent).toContain("never starts traffic");
485
+
486
+ shadow.querySelector<HTMLButtonElement>('[data-action="modal-save-close"]')?.click();
487
+ await vi.waitFor(() => expect(controller.exit).toHaveBeenCalled());
488
+ expect(controller.saveNow).toHaveBeenCalled();
489
+ cleanup();
490
+ });
491
+
492
+ it("keeps the editor open when Save and close cannot save", async () => {
493
+ const controller = makeController();
494
+ controller.saveNow.mockImplementationOnce(async () => {
495
+ controller.emit(state({ saveState: "error", saveMessage: "Network unavailable" }));
496
+ });
497
+ const { shadow, cleanup } = mount(controller);
498
+
499
+ shadow.querySelector<HTMLButtonElement>('[data-action="done"]')?.click();
500
+ shadow.querySelector<HTMLButtonElement>('[data-action="modal-save-close"]')?.click();
501
+
502
+ await vi.waitFor(() => expect(controller.saveNow).toHaveBeenCalled());
503
+ expect(controller.exit).not.toHaveBeenCalled();
504
+ expect(shadow.textContent).toContain("Done editing?");
505
+ cleanup();
506
+ });
507
+
508
+ it("extends the session and refreshes the displayed time", async () => {
509
+ const controller = makeController();
510
+ const extended = new Date(Date.now() + 4 * 60 * 1000).toISOString();
511
+ controller.session.expiresAt = new Date(Date.now() + 60 * 1000).toISOString();
512
+ controller.extendSession.mockImplementationOnce(async () => {
513
+ controller.session.expiresAt = extended;
514
+ return extended;
515
+ });
516
+ const { shadow, cleanup } = mount(controller);
517
+
518
+ expect(shadow.textContent).toContain("Session ends in");
519
+ shadow.querySelector<HTMLButtonElement>('[data-action="extend-session"]')?.click();
520
+
521
+ await vi.waitFor(() => expect(controller.extendSession).toHaveBeenCalledOnce());
522
+ await vi.waitFor(() => expect(shadow.textContent).toMatch(/Session ends in 3:5\d/));
523
+ cleanup();
524
+ });
525
+
526
+ it("enters inspect mode with the loud selection treatment", () => {
527
+ const controller = makeController();
528
+ const { shadow, cleanup } = mount(controller);
529
+
530
+ controller.emit(state({ pickerActive: true }));
531
+ expect(shadow.textContent).toContain("Click an element to talk about it");
532
+ expect(shadow.querySelector(".page-border")).not.toBeNull();
533
+ cleanup();
534
+ });
535
+ });