@shoppexio/builder-runtime 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.
@@ -0,0 +1,430 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { createEmptyBuilderSettings, type BlockInstance, type BuilderSettings } from '@shoppex/builder-contracts';
3
+ import { JSDOM } from 'jsdom';
4
+ import { act } from 'react';
5
+ import { createRoot, type Root } from 'react-dom/client';
6
+ import {
7
+ BuilderBlockFrame,
8
+ BuilderBlockProvider,
9
+ BuilderPage,
10
+ BuilderRuntimePreviewProvider,
11
+ useBuilderContent,
12
+ useBuilderContentRecord,
13
+ } from './react.js';
14
+
15
+ function createSettings(revision: number, title: string): BuilderSettings {
16
+ return {
17
+ ...createEmptyBuilderSettings(revision),
18
+ theme: {
19
+ content: {
20
+ 'hero.title': title,
21
+ },
22
+ layout: {
23
+ home: {
24
+ blocks: [
25
+ {
26
+ id: 'hero-1',
27
+ type: 'hero',
28
+ visible: true,
29
+ settings: {},
30
+ },
31
+ ],
32
+ },
33
+ },
34
+ style_slots: {},
35
+ pages: [],
36
+ terms: {},
37
+ },
38
+ };
39
+ }
40
+
41
+ function Probe() {
42
+ const title = useBuilderContent('hero.title', '');
43
+
44
+ return (
45
+ <section data-builder-block="hero-1" data-builder-block-type="hero" data-page-id="home">
46
+ <h1 data-builder-content="hero.title">{title}</h1>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ function FallbackProbe() {
52
+ const title = useBuilderContent('hero.title', 'Default title');
53
+
54
+ return <h1 data-builder-content="hero.title">{title}</h1>;
55
+ }
56
+
57
+ function SlotButtonProbe() {
58
+ return (
59
+ <section data-builder-block="hero-1" data-builder-block-type="hero" data-page-id="home">
60
+ <button type="button" data-builder-slot="button.background">
61
+ Buy now
62
+ </button>
63
+ </section>
64
+ );
65
+ }
66
+
67
+ function ScopedProbe() {
68
+ return (
69
+ <BuilderBlockProvider
70
+ block={{
71
+ id: 'hero-1',
72
+ type: 'hero',
73
+ visible: true,
74
+ settings: {
75
+ title: 'Scoped title',
76
+ subtitle: 'Scoped subtitle',
77
+ },
78
+ }}
79
+ >
80
+ <ScopedProbeContent />
81
+ </BuilderBlockProvider>
82
+ );
83
+ }
84
+
85
+ function ScopedProbeContent() {
86
+ const title = useBuilderContent('hero.title', '');
87
+ const subtitle = useBuilderContent('hero.subtitle', '');
88
+ const contentRecord = useBuilderContentRecord();
89
+
90
+ return (
91
+ <>
92
+ <h1 data-testid="scoped-title">{title}</h1>
93
+ <p data-testid="scoped-subtitle">{subtitle}</p>
94
+ <span data-testid="scoped-record">{String((contentRecord.hero as Record<string, unknown>).title)}</span>
95
+ </>
96
+ );
97
+ }
98
+
99
+ function HeroBlock({ block }: { block: BlockInstance }) {
100
+ const title = useBuilderContent('hero.title', '');
101
+
102
+ return (
103
+ <BuilderBlockFrame as="section" pageId="home" block={block}>
104
+ <h1 data-builder-content="hero.title">{title}</h1>
105
+ </BuilderBlockFrame>
106
+ );
107
+ }
108
+
109
+ describe('BuilderRuntimePreviewProvider', () => {
110
+ let dom: JSDOM;
111
+ let root: Root;
112
+ let postedMessages: unknown[];
113
+ let parentWindow: { postMessage: (message: unknown, targetOrigin: string) => void };
114
+
115
+ beforeEach(() => {
116
+ postedMessages = [];
117
+ parentWindow = {
118
+ postMessage: (message) => {
119
+ postedMessages.push(message);
120
+ },
121
+ };
122
+
123
+ dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
124
+ url: 'https://preview.shoppex.test/?shoppex-preview-mode=theme',
125
+ referrer: 'https://dashboard.shoppex.test/theme/editor',
126
+ });
127
+
128
+ Object.defineProperty(dom.window, 'parent', {
129
+ configurable: true,
130
+ value: parentWindow,
131
+ });
132
+
133
+ Object.assign(globalThis, {
134
+ window: dom.window,
135
+ document: dom.window.document,
136
+ navigator: dom.window.navigator,
137
+ Element: dom.window.Element,
138
+ HTMLElement: dom.window.HTMLElement,
139
+ HTMLImageElement: dom.window.HTMLImageElement,
140
+ Node: dom.window.Node,
141
+ MouseEvent: dom.window.MouseEvent,
142
+ MessageEvent: dom.window.MessageEvent,
143
+ CustomEvent: dom.window.CustomEvent,
144
+ CSS: {
145
+ escape: (value: string) => value.replaceAll('"', '\\"'),
146
+ },
147
+ IS_REACT_ACT_ENVIRONMENT: true,
148
+ });
149
+
150
+ root = createRoot(dom.window.document.getElementById('root') as HTMLElement);
151
+ });
152
+
153
+ afterEach(async () => {
154
+ await act(async () => {
155
+ root.unmount();
156
+ });
157
+
158
+ dom.window.close();
159
+ const globals = globalThis as Record<string, unknown>;
160
+ for (const key of [
161
+ 'window',
162
+ 'document',
163
+ 'navigator',
164
+ 'Element',
165
+ 'HTMLElement',
166
+ 'HTMLImageElement',
167
+ 'Node',
168
+ 'MouseEvent',
169
+ 'MessageEvent',
170
+ 'CustomEvent',
171
+ 'CSS',
172
+ 'IS_REACT_ACT_ENVIRONMENT',
173
+ ]) {
174
+ delete globals[key];
175
+ }
176
+ });
177
+
178
+ test('sends READY with the initial revision', async () => {
179
+ await act(async () => {
180
+ root.render(
181
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Initial title')}>
182
+ <Probe />
183
+ </BuilderRuntimePreviewProvider>,
184
+ );
185
+ });
186
+
187
+ expect(postedMessages).toContainEqual({ type: 'READY', revision: 3 });
188
+ expect(dom.window.document.body.textContent).toContain('Initial title');
189
+ });
190
+
191
+ test('normalizes mixed initial builder settings before strict validation', async () => {
192
+ await act(async () => {
193
+ root.render(
194
+ <BuilderRuntimePreviewProvider
195
+ initialSettings={{
196
+ version: 2,
197
+ revision: 9,
198
+ theme: {
199
+ content: {
200
+ 'hero.title': 'Mixed title',
201
+ },
202
+ layout: {
203
+ home: {
204
+ sections: [
205
+ {
206
+ id: 'hero',
207
+ visible: true,
208
+ },
209
+ ],
210
+ },
211
+ },
212
+ tokens_override: {
213
+ colors: {
214
+ primary: '#111827',
215
+ },
216
+ },
217
+ },
218
+ }}
219
+ >
220
+ <Probe />
221
+ </BuilderRuntimePreviewProvider>,
222
+ );
223
+ });
224
+
225
+ expect(postedMessages).toContainEqual({ type: 'READY', revision: 9 });
226
+ expect(dom.window.document.body.textContent).toContain('Mixed title');
227
+ });
228
+
229
+ test('preserves intentionally empty content strings in hooks', async () => {
230
+ await act(async () => {
231
+ root.render(
232
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(4, '')}>
233
+ <FallbackProbe />
234
+ </BuilderRuntimePreviewProvider>,
235
+ );
236
+ });
237
+
238
+ expect(dom.window.document.body.textContent).toBe('');
239
+ });
240
+
241
+ test('responds to explicit READY requests when the parent missed the initial message', async () => {
242
+ await act(async () => {
243
+ root.render(
244
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Initial title')}>
245
+ <Probe />
246
+ </BuilderRuntimePreviewProvider>,
247
+ );
248
+ });
249
+
250
+ postedMessages.length = 0;
251
+
252
+ await act(async () => {
253
+ dom.window.dispatchEvent(
254
+ new dom.window.MessageEvent('message', {
255
+ origin: 'https://dashboard.shoppex.test',
256
+ source: parentWindow as Window,
257
+ data: {
258
+ type: 'REQUEST_READY',
259
+ },
260
+ }),
261
+ );
262
+ });
263
+
264
+ expect(postedMessages).toEqual([{ type: 'READY', revision: 3 }]);
265
+ });
266
+
267
+ test('applies preview state and acknowledges the exact revision', async () => {
268
+ await act(async () => {
269
+ root.render(
270
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Initial title')}>
271
+ <Probe />
272
+ </BuilderRuntimePreviewProvider>,
273
+ );
274
+ });
275
+
276
+ await act(async () => {
277
+ dom.window.dispatchEvent(
278
+ new dom.window.MessageEvent('message', {
279
+ origin: 'https://dashboard.shoppex.test',
280
+ source: parentWindow as Window,
281
+ data: {
282
+ type: 'APPLY_STATE',
283
+ revision: 4,
284
+ state: createSettings(4, 'Updated title'),
285
+ },
286
+ }),
287
+ );
288
+ });
289
+
290
+ expect(postedMessages).toContainEqual({ type: 'APPLIED', revision: 4 });
291
+ expect(dom.window.document.body.textContent).toContain('Updated title');
292
+ });
293
+
294
+ test('renders page blocks through a typed registry and shared block frame', async () => {
295
+ await act(async () => {
296
+ root.render(
297
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Registry title')}>
298
+ <BuilderPage
299
+ pageId="home"
300
+ registry={{
301
+ hero: HeroBlock,
302
+ }}
303
+ context={{}}
304
+ />
305
+ </BuilderRuntimePreviewProvider>,
306
+ );
307
+ });
308
+
309
+ const renderedBlock = dom.window.document.querySelector('[data-builder-block="hero-1"]');
310
+
311
+ expect(renderedBlock?.getAttribute('data-page-id')).toBe('home');
312
+ expect(renderedBlock?.getAttribute('data-builder-block-type')).toBe('hero');
313
+ expect(dom.window.document.body.textContent).toContain('Registry title');
314
+ });
315
+
316
+ test('prefers scoped block settings over global builder content inside a block provider', async () => {
317
+ await act(async () => {
318
+ root.render(
319
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(3, 'Global title')}>
320
+ <ScopedProbe />
321
+ </BuilderRuntimePreviewProvider>,
322
+ );
323
+ });
324
+
325
+ expect(dom.window.document.querySelector('[data-testid="scoped-title"]')?.textContent).toBe('Scoped title');
326
+ expect(dom.window.document.querySelector('[data-testid="scoped-subtitle"]')?.textContent).toBe('Scoped subtitle');
327
+ expect(dom.window.document.querySelector('[data-testid="scoped-record"]')?.textContent).toBe('Scoped title');
328
+ });
329
+
330
+ test('rejects stale preview revisions without changing rendered content', async () => {
331
+ await act(async () => {
332
+ root.render(
333
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(5, 'Current title')}>
334
+ <Probe />
335
+ </BuilderRuntimePreviewProvider>,
336
+ );
337
+ });
338
+
339
+ await act(async () => {
340
+ dom.window.dispatchEvent(
341
+ new dom.window.MessageEvent('message', {
342
+ origin: 'https://dashboard.shoppex.test',
343
+ source: parentWindow as Window,
344
+ data: {
345
+ type: 'APPLY_STATE',
346
+ revision: 4,
347
+ state: createSettings(4, 'Stale title'),
348
+ },
349
+ }),
350
+ );
351
+ });
352
+
353
+ expect(postedMessages).toContainEqual({
354
+ type: 'APPLY_FAILED',
355
+ revision: 4,
356
+ error: 'Stale builder revision',
357
+ });
358
+ expect(dom.window.document.body.textContent).toContain('Current title');
359
+ });
360
+
361
+ test('emits block selection details from iframe clicks', async () => {
362
+ await act(async () => {
363
+ root.render(
364
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(7, 'Clickable title')}>
365
+ <Probe />
366
+ </BuilderRuntimePreviewProvider>,
367
+ );
368
+ });
369
+
370
+ const heading = dom.window.document.querySelector('h1');
371
+ heading?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
372
+
373
+ expect(postedMessages).toContainEqual({
374
+ type: 'ELEMENT_CLICKED',
375
+ revision: 7,
376
+ selection: {
377
+ pageId: 'home',
378
+ blockId: 'hero-1',
379
+ blockType: 'hero',
380
+ contentPath: 'hero.title',
381
+ elementType: 'text',
382
+ },
383
+ });
384
+ });
385
+
386
+ test('classifies buttons before style slots for inspector clicks', async () => {
387
+ await act(async () => {
388
+ root.render(
389
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(7, 'Clickable title')}>
390
+ <SlotButtonProbe />
391
+ </BuilderRuntimePreviewProvider>,
392
+ );
393
+ });
394
+
395
+ const button = dom.window.document.querySelector('button');
396
+ button?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
397
+
398
+ expect(postedMessages).toContainEqual({
399
+ type: 'ELEMENT_CLICKED',
400
+ revision: 7,
401
+ selection: {
402
+ pageId: 'home',
403
+ blockId: 'hero-1',
404
+ blockType: 'hero',
405
+ slotId: 'button.background',
406
+ elementType: 'button',
407
+ },
408
+ });
409
+ });
410
+
411
+ test('does not intercept clicks outside trusted builder preview embeds', async () => {
412
+ (dom as JSDOM & { reconfigure: (options: { url: string }) => void }).reconfigure({
413
+ url: 'https://preview.shoppex.test/',
414
+ });
415
+
416
+ await act(async () => {
417
+ root.render(
418
+ <BuilderRuntimePreviewProvider initialSettings={createSettings(7, 'Clickable title')}>
419
+ <Probe />
420
+ </BuilderRuntimePreviewProvider>,
421
+ );
422
+ });
423
+
424
+ postedMessages.length = 0;
425
+ const heading = dom.window.document.querySelector('h1');
426
+ heading?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
427
+
428
+ expect(postedMessages).toEqual([]);
429
+ });
430
+ });