@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,154 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EditorApi, EditorApiError } from "./api";
3
+ import type { ActiveEditorSession } from "./types";
4
+
5
+ const session: ActiveEditorSession = {
6
+ sid: "session/a",
7
+ bearer: "secret-bearer",
8
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
9
+ shopId: "shop-1",
10
+ experimentId: "experiment-1",
11
+ initialVariationId: "variation 1",
12
+ targetUrl: "https://shop.example/products/one",
13
+ targetOrigin: "https://shop.example",
14
+ variations: [],
15
+ };
16
+
17
+ describe("EditorApi", () => {
18
+ it("uses the browser fetch receiver safely when no implementation is injected", async () => {
19
+ const originalFetch = globalThis.fetch;
20
+ const fetchMock = vi.fn(async () =>
21
+ Response.json({
22
+ ok: true,
23
+ variation: {
24
+ id: "variation-1",
25
+ name: "Treatment",
26
+ artifactHash: "hash-1",
27
+ reviewStatus: null,
28
+ },
29
+ artifacts: { mutations: [], customCss: "", customJs: "" },
30
+ }),
31
+ );
32
+ globalThis.fetch = fetchMock as typeof fetch;
33
+ try {
34
+ const api = new EditorApi("https://app.test", session);
35
+ await api.loadArtifacts("variation-1");
36
+ expect(fetchMock).toHaveBeenCalledOnce();
37
+ } finally {
38
+ globalThis.fetch = originalFetch;
39
+ }
40
+ });
41
+ it("loads artifacts with bearer auth and encoded scope", async () => {
42
+ const fetchMock = vi.fn(async () => ({
43
+ ok: true,
44
+ status: 200,
45
+ json: async () => ({
46
+ ok: true,
47
+ variation: { id: "variation 1", name: "Treatment", artifactHash: "hash-1", reviewStatus: "approved" },
48
+ artifacts: { mutations: [], customCss: "", customJs: "" },
49
+ }),
50
+ })) as unknown as typeof fetch;
51
+ const api = new EditorApi("https://app.example", session, fetchMock);
52
+ await api.loadArtifacts("variation 1");
53
+ expect(fetchMock).toHaveBeenCalledWith(
54
+ "https://app.example/api/editor-sessions/session%2Fa/artifacts?variationId=variation+1",
55
+ expect.objectContaining({
56
+ method: "GET",
57
+ credentials: "omit",
58
+ headers: expect.objectContaining({ Authorization: "Bearer secret-bearer" }),
59
+ }),
60
+ );
61
+ });
62
+
63
+ it("extends the session and returns the new expiry", async () => {
64
+ const later = new Date(Date.now() + 3_600_000).toISOString();
65
+ const fetchMock = vi.fn(async () => ({
66
+ ok: true,
67
+ status: 200,
68
+ json: async () => ({ ok: true, session: { sid: "session/a", expiresAt: later } }),
69
+ })) as unknown as typeof fetch;
70
+ const api = new EditorApi("https://app.example", session, fetchMock);
71
+ await expect(api.extendSession()).resolves.toBe(later);
72
+ expect(fetchMock).toHaveBeenCalledWith(
73
+ "https://app.example/api/editor-sessions/session%2Fa/extend",
74
+ expect.objectContaining({
75
+ method: "POST",
76
+ headers: expect.objectContaining({ Authorization: "Bearer secret-bearer" }),
77
+ }),
78
+ );
79
+ });
80
+
81
+ it("sends a full optimistic snapshot and surfaces conflicts", async () => {
82
+ const fetchMock = vi.fn(async () => ({
83
+ ok: false,
84
+ status: 409,
85
+ json: async () => ({ ok: false, error: "Artifact hash conflict", artifactHash: "new-hash" }),
86
+ })) as unknown as typeof fetch;
87
+ const api = new EditorApi("https://app.example", session, fetchMock);
88
+ await expect(api.saveArtifacts("variation-1", {
89
+ mutations: [], customCss: "h1{color:red}", customJs: "",
90
+ }, "old-hash")).rejects.toMatchObject<Partial<EditorApiError>>({ status: 409 });
91
+ expect(fetchMock).toHaveBeenCalledWith(
92
+ expect.stringContaining("variationId=variation-1"),
93
+ expect.objectContaining({
94
+ method: "PATCH",
95
+ body: JSON.stringify({ mutations: [], customCss: "h1{color:red}", customJs: "", expectedPriorHash: "old-hash" }),
96
+ }),
97
+ );
98
+ });
99
+
100
+ it("rejects load responses without the required optimistic artifact hash", async () => {
101
+ const fetchMock = vi.fn(async () => ({
102
+ ok: true,
103
+ status: 200,
104
+ json: async () => ({
105
+ ok: true,
106
+ variation: { id: "variation 1", name: "Treatment" },
107
+ artifacts: { mutations: [], customCss: "", customJs: "" },
108
+ }),
109
+ })) as unknown as typeof fetch;
110
+ const api = new EditorApi("https://app.example", session, fetchMock);
111
+ await expect(api.loadArtifacts("variation 1")).rejects.toThrow(
112
+ "Editor load response did not include an artifact hash",
113
+ );
114
+ });
115
+
116
+ it("claims and completes durable browser commands with bearer auth", async () => {
117
+ const fetchMock = vi.fn()
118
+ .mockResolvedValueOnce({
119
+ ok: true,
120
+ status: 200,
121
+ json: async () => ({
122
+ ok: true,
123
+ commands: [{
124
+ commandId: "command-1",
125
+ command: "snapshot",
126
+ args: {},
127
+ expiresAt: new Date(Date.now() + 10_000).toISOString(),
128
+ }],
129
+ }),
130
+ })
131
+ .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ok: true }) }) as typeof fetch;
132
+ const api = new EditorApi("https://app.example", session, fetchMock);
133
+
134
+ await expect(api.claimCommands()).resolves.toEqual([
135
+ expect.objectContaining({ commandId: "command-1", command: "snapshot" }),
136
+ ]);
137
+ await api.completeCommand("command-1", { result: { ok: true } });
138
+
139
+ expect(fetchMock).toHaveBeenNthCalledWith(1,
140
+ "https://app.example/api/editor-sessions/session%2Fa/commands?limit=10",
141
+ expect.objectContaining({
142
+ method: "GET",
143
+ headers: expect.objectContaining({ Authorization: "Bearer secret-bearer" }),
144
+ }),
145
+ );
146
+ expect(fetchMock).toHaveBeenNthCalledWith(2,
147
+ "https://app.example/api/editor-sessions/session%2Fa/commands",
148
+ expect.objectContaining({
149
+ method: "PATCH",
150
+ body: JSON.stringify({ commandId: "command-1", result: { ok: true } }),
151
+ }),
152
+ );
153
+ });
154
+ });
@@ -0,0 +1,185 @@
1
+ import type {
2
+ ActiveEditorSession,
3
+ ClaimedEditorCommand,
4
+ EditorArtifacts,
5
+ EditorBrowserCommand,
6
+ EditorVariation,
7
+ } from "./types";
8
+
9
+ export class EditorApiError extends Error {
10
+ constructor(
11
+ message: string,
12
+ readonly status: number,
13
+ readonly payload?: Record<string, unknown> | null,
14
+ ) {
15
+ super(message);
16
+ }
17
+ }
18
+
19
+ type FetchLike = typeof fetch;
20
+
21
+ function record(value: unknown): Record<string, unknown> | null {
22
+ return value && typeof value === "object" && !Array.isArray(value)
23
+ ? value as Record<string, unknown>
24
+ : null;
25
+ }
26
+
27
+ function parseArtifacts(value: unknown): EditorArtifacts {
28
+ const input = record(value) ?? {};
29
+ return {
30
+ mutations: Array.isArray(input.mutations) ? input.mutations as EditorArtifacts["mutations"] : [],
31
+ customCss: typeof input.customCss === "string" ? input.customCss : "",
32
+ customJs: typeof input.customJs === "string" ? input.customJs : "",
33
+ };
34
+ }
35
+
36
+ const EDITOR_COMMANDS = new Set<EditorBrowserCommand>([
37
+ "navigate", "reload", "back", "forward", "click", "type", "wait_for",
38
+ "snapshot", "screenshot", "eval_readonly", "console_logs", "network_logs",
39
+ "network_probe", "current_url", "select_element", "apply_artifacts",
40
+ ]);
41
+
42
+ export class EditorApi {
43
+ constructor(
44
+ private readonly apiOrigin: string,
45
+ private readonly session: ActiveEditorSession,
46
+ private readonly fetchImpl: FetchLike = (...args) => fetch(...args),
47
+ ) {}
48
+
49
+ private url(path: string, variationId?: string): string {
50
+ const url = new URL(path, this.apiOrigin);
51
+ if (variationId) url.searchParams.set("variationId", variationId);
52
+ return url.toString();
53
+ }
54
+
55
+ private async request(
56
+ url: string,
57
+ init: RequestInit,
58
+ ): Promise<Record<string, unknown>> {
59
+ const response = await this.fetchImpl(url, {
60
+ ...init,
61
+ credentials: "omit",
62
+ cache: "no-store",
63
+ referrerPolicy: "no-referrer",
64
+ headers: {
65
+ Authorization: `Bearer ${this.session.bearer}`,
66
+ ...(init.body ? { "Content-Type": "application/json" } : {}),
67
+ ...(init.headers ?? {}),
68
+ },
69
+ });
70
+ const payload = record(await response.json().catch(() => null));
71
+ if (!response.ok || payload?.ok === false) {
72
+ throw new EditorApiError(
73
+ typeof payload?.error === "string" ? payload.error : `Editor API failed (${response.status})`,
74
+ response.status,
75
+ payload,
76
+ );
77
+ }
78
+ return payload ?? {};
79
+ }
80
+
81
+ async loadArtifacts(variationId: string): Promise<{
82
+ variation: EditorVariation;
83
+ artifacts: EditorArtifacts;
84
+ }> {
85
+ const payload = await this.request(
86
+ this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}/artifacts`, variationId),
87
+ { method: "GET" },
88
+ );
89
+ const variation = record(payload.variation) ?? {};
90
+ if (typeof variation.artifactHash !== "string" || !variation.artifactHash) {
91
+ throw new EditorApiError("Editor load response did not include an artifact hash", 500, payload);
92
+ }
93
+ return {
94
+ variation: {
95
+ id: typeof variation.id === "string" ? variation.id : variationId,
96
+ name: typeof variation.name === "string" ? variation.name : "Variation",
97
+ artifactHash: variation.artifactHash,
98
+ reviewStatus: typeof variation.reviewStatus === "string" ? variation.reviewStatus : null,
99
+ },
100
+ artifacts: parseArtifacts(payload.artifacts),
101
+ };
102
+ }
103
+
104
+ async saveArtifacts(
105
+ variationId: string,
106
+ artifacts: EditorArtifacts,
107
+ expectedPriorHash: string,
108
+ ): Promise<{
109
+ artifactHash: string;
110
+ reviewStatus: string | null;
111
+ warningIssues: Array<{ code?: string; message?: string }>;
112
+ artifacts: EditorArtifacts;
113
+ }> {
114
+ const payload = await this.request(
115
+ this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}/artifacts`, variationId),
116
+ {
117
+ method: "PATCH",
118
+ body: JSON.stringify({ ...artifacts, expectedPriorHash }),
119
+ },
120
+ );
121
+ if (typeof payload.artifactHash !== "string") {
122
+ throw new EditorApiError("Editor save response did not include an artifact hash", 500, payload);
123
+ }
124
+ return {
125
+ artifactHash: payload.artifactHash,
126
+ reviewStatus: typeof payload.reviewStatus === "string" ? payload.reviewStatus : null,
127
+ warningIssues: Array.isArray(payload.warningIssues)
128
+ ? payload.warningIssues as Array<{ code?: string; message?: string }>
129
+ : [],
130
+ artifacts: parseArtifacts(payload.artifacts),
131
+ };
132
+ }
133
+
134
+ async extendSession(): Promise<string | null> {
135
+ const payload = await this.request(
136
+ this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}/extend`),
137
+ { method: "POST" },
138
+ );
139
+ const session = record(payload.session);
140
+ return typeof session?.expiresAt === "string" ? session.expiresAt : null;
141
+ }
142
+
143
+ async revoke(): Promise<void> {
144
+ await this.request(
145
+ this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}`),
146
+ { method: "DELETE" },
147
+ );
148
+ }
149
+
150
+
151
+ async claimCommands(limit = 10): Promise<ClaimedEditorCommand[]> {
152
+ const url = this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}/commands`);
153
+ const commandUrl = new URL(url);
154
+ commandUrl.searchParams.set("limit", String(Math.max(1, Math.min(limit, 20))));
155
+ const payload = await this.request(commandUrl.toString(), { method: "GET" });
156
+ if (!Array.isArray(payload.commands)) return [];
157
+ const commands: ClaimedEditorCommand[] = [];
158
+ for (const value of payload.commands) {
159
+ const command = record(value);
160
+ const commandId = typeof command?.commandId === "string" ? command.commandId : "";
161
+ const commandName = typeof command?.command === "string" ? command.command : "";
162
+ if (!commandId || !EDITOR_COMMANDS.has(commandName as EditorBrowserCommand)) continue;
163
+ commands.push({
164
+ commandId,
165
+ command: commandName as EditorBrowserCommand,
166
+ args: record(command?.args) ?? {},
167
+ expiresAt: typeof command?.expiresAt === "string" ? command.expiresAt : null,
168
+ });
169
+ }
170
+ return commands;
171
+ }
172
+
173
+ async completeCommand(
174
+ commandId: string,
175
+ completion: { result: Record<string, unknown> } | { error: string },
176
+ ): Promise<void> {
177
+ await this.request(
178
+ this.url(`/api/editor-sessions/${encodeURIComponent(this.session.sid)}/commands`),
179
+ {
180
+ method: "PATCH",
181
+ body: JSON.stringify({ commandId, ...completion }),
182
+ },
183
+ );
184
+ }
185
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ArtifactHistory, upsertMutation } from "./artifacts";
3
+ import type { EditorArtifacts } from "./types";
4
+
5
+ const empty = (): EditorArtifacts => ({ mutations: [], customCss: "", customJs: "" });
6
+
7
+ describe("artifact mutation upsert", () => {
8
+ it("replaces the content slot and merges style properties", () => {
9
+ let artifacts = upsertMutation(empty(), { selector: "h1", action: "text", text: "First" });
10
+ artifacts = upsertMutation(artifacts, { selector: "h1", action: "setText", text: "Second" });
11
+ artifacts = upsertMutation(artifacts, { selector: "h1", action: "style", styles: { color: "red" } });
12
+ artifacts = upsertMutation(artifacts, { selector: "h1", action: "setStyle", styles: { margin: "8px" } });
13
+
14
+ expect(artifacts.mutations).toEqual([
15
+ { selector: "h1", action: "setText", text: "Second" },
16
+ { selector: "h1", action: "setStyle", styles: { color: "red", margin: "8px" } },
17
+ ]);
18
+ });
19
+
20
+ it("normalizes and merges attribute forms", () => {
21
+ let artifacts = upsertMutation(empty(), {
22
+ selector: "img",
23
+ action: "attribute",
24
+ attribute: { name: "src", value: "/one.jpg" },
25
+ });
26
+ artifacts = upsertMutation(artifacts, {
27
+ selector: "img",
28
+ action: "attribute",
29
+ attributes: { alt: "Treatment" },
30
+ });
31
+ expect(artifacts.mutations[0]).toMatchObject({
32
+ selector: "img",
33
+ action: "attribute",
34
+ attributes: { src: "/one.jpg", alt: "Treatment" },
35
+ });
36
+ });
37
+
38
+ it("makes remove terminal for the selector", () => {
39
+ let artifacts = upsertMutation(empty(), { selector: ".promo", action: "setText", text: "Offer" });
40
+ artifacts = upsertMutation(artifacts, { selector: ".promo", action: "setStyle", styles: { color: "red" } });
41
+ artifacts = upsertMutation(artifacts, { selector: ".promo", action: "remove" });
42
+ expect(artifacts.mutations).toEqual([{ selector: ".promo", action: "remove" }]);
43
+ });
44
+ });
45
+
46
+ describe("ArtifactHistory", () => {
47
+ it("supports immutable undo, redo, and redo invalidation", () => {
48
+ const history = new ArtifactHistory();
49
+ const initial = empty();
50
+ const changed = { ...empty(), customCss: "h1{color:red}" };
51
+ history.push(initial);
52
+ expect(history.undo(changed)).toEqual(initial);
53
+ expect(history.redo(initial)).toEqual(changed);
54
+ history.push(changed);
55
+ expect(history.canRedo).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,115 @@
1
+ import type { Mutation } from "../types";
2
+ import { cloneArtifacts, type EditorArtifacts } from "./types";
3
+
4
+ function sameMutationSlot(left: Mutation, right: Mutation): boolean {
5
+ if (left.selector !== right.selector) return false;
6
+ const textActions = new Set(["text", "setText", "html"]);
7
+ const styleActions = new Set(["style", "setStyle"]);
8
+ if (textActions.has(left.action) && textActions.has(right.action)) return true;
9
+ if (styleActions.has(left.action) && styleActions.has(right.action)) return true;
10
+ return left.action === right.action;
11
+ }
12
+
13
+ export function upsertMutation(
14
+ artifacts: EditorArtifacts,
15
+ mutation: Mutation,
16
+ ): EditorArtifacts {
17
+ if (mutation.action === "remove") {
18
+ return {
19
+ ...artifacts,
20
+ mutations: [
21
+ ...artifacts.mutations.filter((item) => item.selector !== mutation.selector),
22
+ mutation,
23
+ ],
24
+ };
25
+ }
26
+ const mutations = artifacts.mutations
27
+ .filter((item) => !(item.selector === mutation.selector && item.action === "remove"))
28
+ .map((item) => ({ ...item }));
29
+ const index = mutations.findIndex((item) => sameMutationSlot(item, mutation));
30
+ if (index < 0) mutations.push(mutation);
31
+ else if ((mutation.action === "style" || mutation.action === "setStyle") && mutations[index].styles) {
32
+ mutations[index] = {
33
+ ...mutations[index],
34
+ ...mutation,
35
+ styles: { ...mutations[index].styles, ...mutation.styles },
36
+ };
37
+ } else if (mutation.action === "attribute") {
38
+ const previous = mutations[index];
39
+ const previousAttributes = {
40
+ ...(previous.attribute ? { [previous.attribute.name]: previous.attribute.value } : {}),
41
+ ...(previous.attributes ?? {}),
42
+ };
43
+ const nextAttributes = {
44
+ ...(mutation.attribute ? { [mutation.attribute.name]: mutation.attribute.value } : {}),
45
+ ...(mutation.attributes ?? {}),
46
+ };
47
+ mutations[index] = {
48
+ ...previous,
49
+ ...mutation,
50
+ attribute: undefined,
51
+ attributes: { ...previousAttributes, ...nextAttributes },
52
+ };
53
+ } else {
54
+ mutations[index] = mutation;
55
+ }
56
+ return { ...artifacts, mutations };
57
+ }
58
+
59
+ export function removeSelectorMutations(
60
+ artifacts: EditorArtifacts,
61
+ selector: string,
62
+ ): EditorArtifacts {
63
+ return {
64
+ ...artifacts,
65
+ mutations: artifacts.mutations.filter((mutation) => mutation.selector !== selector),
66
+ };
67
+ }
68
+
69
+ export function removeMutationAt(
70
+ artifacts: EditorArtifacts,
71
+ index: number,
72
+ ): EditorArtifacts {
73
+ return {
74
+ ...artifacts,
75
+ mutations: artifacts.mutations.filter((_, mutationIndex) => mutationIndex !== index),
76
+ };
77
+ }
78
+
79
+ export class ArtifactHistory {
80
+ private undoStack: EditorArtifacts[] = [];
81
+ private redoStack: EditorArtifacts[] = [];
82
+
83
+ push(current: EditorArtifacts): void {
84
+ this.undoStack.push(cloneArtifacts(current));
85
+ this.undoStack = this.undoStack.slice(-50);
86
+ this.redoStack = [];
87
+ }
88
+
89
+ undo(current: EditorArtifacts): EditorArtifacts | null {
90
+ const previous = this.undoStack.pop();
91
+ if (!previous) return null;
92
+ this.redoStack.push(cloneArtifacts(current));
93
+ return cloneArtifacts(previous);
94
+ }
95
+
96
+ redo(current: EditorArtifacts): EditorArtifacts | null {
97
+ const next = this.redoStack.pop();
98
+ if (!next) return null;
99
+ this.undoStack.push(cloneArtifacts(current));
100
+ return cloneArtifacts(next);
101
+ }
102
+
103
+ reset(): void {
104
+ this.undoStack = [];
105
+ this.redoStack = [];
106
+ }
107
+
108
+ get canUndo(): boolean {
109
+ return this.undoStack.length > 0;
110
+ }
111
+
112
+ get canRedo(): boolean {
113
+ return this.redoStack.length > 0;
114
+ }
115
+ }