@akinon/pz-theme 2.0.93-rc.0 → 2.0.94-beta.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.
package/CHANGELOG.md CHANGED
@@ -1,38 +1,27 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.93-rc.0
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [86187b1c]
8
- - Updated dependencies [0cf9ea239]
9
- - Updated dependencies [324f97d55]
10
- - Updated dependencies [51ea06888]
11
- - Updated dependencies
12
- - Updated dependencies [2fea41430]
13
- - Updated dependencies [b55acb768]
14
- - Updated dependencies [760258c1c]
15
- - Updated dependencies [143be2b9d]
16
- - Updated dependencies [7889b08fe]
17
- - Updated dependencies [9f8cd3bc5]
18
- - Updated dependencies [bfafa3f49]
19
- - Updated dependencies [57d7eb305]
20
- - Updated dependencies [d99a6a7d5]
21
- - Updated dependencies [9db81a714]
22
- - Updated dependencies [591e345e1]
23
- - Updated dependencies [4de5303c5]
24
- - Updated dependencies [95b139dc1]
25
- - Updated dependencies [1d00f2d06]
26
- - Updated dependencies [4ac7b2a1e]
27
- - Updated dependencies [e9598c71d]
28
- - Updated dependencies [4998a9631]
29
- - Updated dependencies [804d2bd6c]
30
- - Updated dependencies [3909d3224]
31
- - Updated dependencies [6a3d8a631]
32
- - Updated dependencies [e18836b20]
33
- - Updated dependencies [b1111d7b]
34
- - @akinon/next@2.0.93-rc.0
35
- - @akinon/theme-contract@2.0.93-rc.0
3
+ ## 2.0.94-beta.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [cbbbfd757]
8
+ - @akinon/next@2.0.94-beta.0
9
+ - @akinon/theme-contract@2.0.94-beta.0
10
+
11
+ ## 2.0.93
12
+
13
+ ### Patch Changes
14
+
15
+ - fc6b433f: ZERO-5000: Server-render carousel slides so sliders have height before hydration
16
+
17
+ Both carousel call sites rendered `<Carousel>` without `ssr`/`deviceType`, so the library returned no slides on the server and every slider shipped an empty track — zero height at first paint, full height once hydrated. On a storefront home page that meant the page opened on its second section and measured a CLS of 0.70.
18
+
19
+ Includes a behaviour change merchants can see: `slider-block`'s responsive map switched item counts at 768px while the block's own stylesheet, its `slidesToSlide` picker and the sibling posts slider all drew the line at 1024. It now draws it at 1024 too, so a slider between 768px and 1023px shows the mobile item count rather than the desktop one.
20
+
21
+ Also stops a video's native control bar appearing at first paint: `controls` and `poster` are stored per breakpoint and were being read raw, so controls were on for every video whatever the merchant chose and a poster they typed was silently dropped.
22
+
23
+ - @akinon/next@2.0.93
24
+ - @akinon/theme-contract@2.0.93
36
25
 
37
26
  ## 2.0.92
38
27
 
package/jest/setup.ts CHANGED
@@ -17,6 +17,56 @@ if (!window.matchMedia) {
17
17
 
18
18
  window.scrollTo = (() => {}) as typeof window.scrollTo;
19
19
 
20
+ // `react-dom/server` builds a MessageChannel at module scope, and jsdom has
21
+ // none — importing it in a jsdom suite throws before a single test runs. Node's
22
+ // own MessageChannel is not the answer: its ports keep the event loop alive, so
23
+ // jest hangs after the last test instead of exiting.
24
+ //
25
+ // `renderToString` is synchronous and never waits on this channel; it only
26
+ // needs the constructor to exist. So the shim delivers through a macrotask and
27
+ // holds nothing. Needed by any test that asserts what a component emits on the
28
+ // SERVER, which is the only way to catch a component that renders nothing until
29
+ // it hydrates.
30
+ class ShimMessagePort {
31
+ onmessage: ((event: { data: unknown }) => void) | null = null;
32
+ peer: ShimMessagePort | null = null;
33
+
34
+ postMessage(data: unknown) {
35
+ const peer = this.peer;
36
+ if (!peer) return;
37
+ setTimeout(() => peer.onmessage?.({ data }), 0);
38
+ }
39
+
40
+ start() {}
41
+ close() {}
42
+ addEventListener() {}
43
+ removeEventListener() {}
44
+ }
45
+
46
+ class ShimMessageChannel {
47
+ port1 = new ShimMessagePort();
48
+ port2 = new ShimMessagePort();
49
+
50
+ constructor() {
51
+ this.port1.peer = this.port2;
52
+ this.port2.peer = this.port1;
53
+ }
54
+ }
55
+
56
+ const globals = globalThis as unknown as Record<string, unknown>;
57
+ if (typeof globals.MessageChannel === 'undefined') {
58
+ globals.MessageChannel = ShimMessageChannel;
59
+ }
60
+
61
+ // The other half of the same gap: the server renderer encodes its output, and
62
+ // jsdom exposes no TextEncoder/TextDecoder. Node's are the real implementations
63
+ // and hold nothing open.
64
+ if (typeof globals.TextEncoder === 'undefined') {
65
+ const { TextEncoder, TextDecoder } = require('util');
66
+ globals.TextEncoder = TextEncoder;
67
+ globals.TextDecoder = TextDecoder;
68
+ }
69
+
20
70
  class NoopObserver {
21
71
  observe() {}
22
72
  unobserve() {}
package/package.json CHANGED
@@ -1,26 +1,27 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.93-rc.0",
4
+ "version": "2.0.94-beta.0",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
7
  "scripts": {
8
8
  "test": "jest"
9
9
  },
10
10
  "peerDependencies": {
11
- "@akinon/next": "2.0.93-rc.0",
11
+ "@akinon/next": "2.0.94-beta.0",
12
12
  "react": "^18.0.0 || ^19.0.0",
13
13
  "react-dom": "^18.0.0 || ^19.0.0"
14
14
  },
15
15
  "dependencies": {
16
- "@akinon/theme-contract": "2.0.93-rc.0",
16
+ "@akinon/theme-contract": "2.0.94-beta.0",
17
17
  "clsx": "2.1.1",
18
18
  "lottie-web": "^5.13.0",
19
+ "react-multi-carousel": "2.8.4",
19
20
  "tailwind-merge": "^2.5.4",
20
21
  "zod": "4.4.1"
21
22
  },
22
23
  "devDependencies": {
23
- "@akinon/next": "2.0.93-rc.0",
24
+ "@akinon/next": "2.0.94-beta.0",
24
25
  "@testing-library/jest-dom": "5.16.4",
25
26
  "@testing-library/react": "16.3.2",
26
27
  "@testing-library/user-event": "14.1.1",
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Slides that exist before hydration — asserted on the markup, with the REAL
3
+ * carousel library.
4
+ *
5
+ * Every other slider test in this package mocks `react-multi-carousel` away,
6
+ * which is right for testing what this package decides and useless for testing
7
+ * what the library emits. The bug being pinned here lived entirely inside the
8
+ * library: without `ssr`/`deviceType` its `CarouselItems` returns `null` on the
9
+ * server, so the track shipped empty, every slider band was 0px at first paint
10
+ * and the page opened on its second section. A test that stubbed the library
11
+ * would have gone on passing throughout.
12
+ *
13
+ * So: no carousel mock. Only `ThemeBlock` is stubbed, because what a slide
14
+ * CONTAINS is another file's subject and pulling the real one in would drag the
15
+ * whole renderer into a test about `<li>` elements.
16
+ *
17
+ * Note the jsdom caveat that makes these assertions possible at all: jsdom
18
+ * reports `offsetWidth: 0`, so `domFullyLoaded` is never true here and the
19
+ * library stays on its SSR path for the whole test. The gate this package owns
20
+ * is independent of that — it is React state, and it drops on its own effect,
21
+ * which is what "the gate is gone after mount" below actually measures.
22
+ */
23
+ import React from 'react';
24
+ import { act, render } from '@testing-library/react';
25
+ import { renderToString } from 'react-dom/server';
26
+ import { hydrateRoot } from 'react-dom/client';
27
+
28
+ jest.mock('../../theme-block', () => ({
29
+ __esModule: true,
30
+ default: ({ block }: { block: { id: string } }) => (
31
+ <div data-testid="slide-content" data-id={block.id} />
32
+ )
33
+ }));
34
+
35
+ import SliderBlock from '../slider-block';
36
+ import PostsSliderSection from '../../sections/posts-slider-section';
37
+ import {
38
+ CAROUSEL_SSR_GATE_ATTRIBUTE,
39
+ CAROUSEL_SSR_SCOPE_PREFIX,
40
+ useCarouselSsr
41
+ } from '../../utils/carousel-ssr';
42
+ import { DESKTOP_MIN_WIDTH } from '../../utils/breakpoints';
43
+
44
+ const slide = (id: string): any => ({
45
+ id,
46
+ type: 'text',
47
+ label: id,
48
+ value: '',
49
+ properties: {},
50
+ styles: {},
51
+ order: 0,
52
+ hidden: false
53
+ });
54
+
55
+ const sliderProps = (
56
+ properties: Record<string, unknown> = {},
57
+ over: Record<string, unknown> = {}
58
+ ) =>
59
+ ({
60
+ block: {
61
+ id: 'featured-brands',
62
+ type: 'slider',
63
+ label: 'slider',
64
+ value: '',
65
+ properties,
66
+ styles: {},
67
+ order: 0,
68
+ hidden: false,
69
+ blocks: ['a', 'b', 'c', 'd', 'e', 'f'].map(slide)
70
+ },
71
+ placeholderId: 'home',
72
+ sectionId: 'sec',
73
+ isDesigner: false,
74
+ ...over
75
+ } as any);
76
+
77
+ const postsProps = (over: Record<string, unknown> = {}) =>
78
+ ({
79
+ section: {
80
+ id: 'complete-the-look',
81
+ type: 'posts-slider',
82
+ label: 'strip',
83
+ properties: { 'items-per-slide': { desktop: 4, tablet: 3, mobile: 2 } },
84
+ styles: {},
85
+ blocks: ['p', 'q', 'r', 's', 't'].map(slide)
86
+ },
87
+ placeholderId: 'home',
88
+ isDesigner: false,
89
+ ...over
90
+ } as any);
91
+
92
+ /** The server's markup, parsed so it can be queried the way a browser would. */
93
+ const serverDom = (element: React.ReactElement): HTMLElement => {
94
+ const host = document.createElement('div');
95
+ host.innerHTML = renderToString(element);
96
+ return host;
97
+ };
98
+
99
+ const slides = (root: ParentNode) =>
100
+ root.querySelectorAll('ul.react-multi-carousel-track > li');
101
+
102
+ describe('the server emits an <li> per slide — it emitted none', () => {
103
+ it('does so for the slider block', () => {
104
+ expect(slides(serverDom(<SliderBlock {...sliderProps()} />))).toHaveLength(
105
+ 6
106
+ );
107
+ });
108
+
109
+ it('does so for the posts slider section', () => {
110
+ expect(
111
+ slides(serverDom(<PostsSliderSection {...postsProps()} />))
112
+ ).toHaveLength(5);
113
+ });
114
+
115
+ it('sizes each of them, rather than leaving the track to collapse', () => {
116
+ const dom = serverDom(<SliderBlock {...sliderProps()} />);
117
+ // Four per view is the block's default, so the library's own SSR basis.
118
+ slides(dom).forEach((item) => {
119
+ expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
120
+ });
121
+ });
122
+ });
123
+
124
+ describe('the style, the gate and the element the selector targets', () => {
125
+ /**
126
+ * The point of resolving the selector against the real markup rather than
127
+ * comparing two strings: a rule can be emitted perfectly and still match
128
+ * nothing, and nothing about the output says so.
129
+ */
130
+ const assertSheetReachesItsTrack = (dom: HTMLElement) => {
131
+ const sheet = dom.querySelector('style');
132
+ expect(sheet).not.toBeNull();
133
+
134
+ const selector = (sheet as HTMLStyleElement).textContent!.split('{')[0];
135
+ expect(selector).toContain(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}=`);
136
+
137
+ const matched = dom.querySelector(selector);
138
+ expect(matched).not.toBeNull();
139
+ expect(matched).toBe(dom.querySelector('ul.react-multi-carousel-track'));
140
+ expect((matched as HTMLElement).className).toContain(
141
+ 'react-multi-carousel-track'
142
+ );
143
+ };
144
+
145
+ it('resolves to the real track in the slider block', () => {
146
+ assertSheetReachesItsTrack(serverDom(<SliderBlock {...sliderProps()} />));
147
+ });
148
+
149
+ it('resolves to the real track in the posts slider section', () => {
150
+ assertSheetReachesItsTrack(
151
+ serverDom(<PostsSliderSection {...postsProps()} />)
152
+ );
153
+ });
154
+
155
+ it('carries the gate on an ancestor and the scope on the carousel itself', () => {
156
+ const dom = serverDom(<SliderBlock {...sliderProps()} />);
157
+
158
+ const gated = dom.querySelector(
159
+ `[${CAROUSEL_SSR_GATE_ATTRIBUTE}="featured-brands"]`
160
+ );
161
+ const scoped = dom.querySelector(
162
+ `.${CAROUSEL_SSR_SCOPE_PREFIX}featured-brands`
163
+ );
164
+
165
+ expect(gated).not.toBeNull();
166
+ expect(scoped).not.toBeNull();
167
+ expect(gated!.contains(scoped!)).toBe(true);
168
+ expect(scoped!.className).toContain('react-multi-carousel-list');
169
+ });
170
+
171
+ it('keeps the scope class on the carousel, not the gate', () => {
172
+ // The scope is what stops a slider nested in another slider's slide being
173
+ // restyled by its parent's item count, so it must not vanish with the gate.
174
+ const dom = serverDom(<PostsSliderSection {...postsProps()} />);
175
+ const scoped = dom.querySelector(
176
+ `.${CAROUSEL_SSR_SCOPE_PREFIX}complete-the-look`
177
+ );
178
+ expect(scoped!.className).toContain('posts-slider-carousel');
179
+ });
180
+ });
181
+
182
+ describe('the client agrees with the server, then lets go', () => {
183
+ const hydrationErrors = (element: React.ReactElement) => {
184
+ const html = renderToString(element);
185
+ const host = document.createElement('div');
186
+ host.innerHTML = html;
187
+ document.body.appendChild(host);
188
+
189
+ const seen: string[] = [];
190
+ const spy = jest
191
+ .spyOn(console, 'error')
192
+ .mockImplementation((...args: unknown[]) => {
193
+ seen.push(args.map(String).join(' '));
194
+ });
195
+
196
+ let root: ReturnType<typeof hydrateRoot>;
197
+ act(() => {
198
+ root = hydrateRoot(host, element);
199
+ });
200
+
201
+ spy.mockRestore();
202
+ return {
203
+ host,
204
+ mismatches: seen.filter((message) =>
205
+ /hydrat|did not match/i.test(message)
206
+ ),
207
+ unmount: () => act(() => root.unmount())
208
+ };
209
+ };
210
+
211
+ it('hydrates the slider block with no mismatch', () => {
212
+ const { mismatches, unmount } = hydrationErrors(
213
+ <SliderBlock {...sliderProps()} />
214
+ );
215
+ expect(mismatches).toEqual([]);
216
+ unmount();
217
+ });
218
+
219
+ it('hydrates the posts slider section with no mismatch', () => {
220
+ const { mismatches, unmount } = hydrationErrors(
221
+ <PostsSliderSection {...postsProps()} />
222
+ );
223
+ expect(mismatches).toEqual([]);
224
+ unmount();
225
+ });
226
+
227
+ it('drops the gate and its stylesheet once mounted', () => {
228
+ const { host, unmount } = hydrationErrors(
229
+ <SliderBlock {...sliderProps()} />
230
+ );
231
+
232
+ expect(host.querySelector(`[${CAROUSEL_SSR_GATE_ATTRIBUTE}]`)).toBeNull();
233
+ expect(host.querySelector('style')).toBeNull();
234
+ // The scope class is not part of the gate and stays.
235
+ expect(
236
+ host.querySelector(`.${CAROUSEL_SSR_SCOPE_PREFIX}featured-brands`)
237
+ ).not.toBeNull();
238
+
239
+ unmount();
240
+ });
241
+
242
+ it('brings the gate back when the carousel remounts', () => {
243
+ /**
244
+ * `posts-slider-section` puts `currentBreakpoint` in its carousel key, so
245
+ * switching device in the editor REMOUNTS the library — back to
246
+ * `domLoaded: false`, back on the SSR path — and the gated sheet has to
247
+ * come back with it or the canvas paints desktop-basis slides for a frame.
248
+ *
249
+ * Asserted on the hook rather than on the DOM because the window being
250
+ * pinned is one RENDER long: `rerender` wraps the update in `act`, which
251
+ * flushes the effect that closes the gate before any query could see it
252
+ * open. So the renders themselves are recorded. Both call sites hand the
253
+ * hook the same `carouselKey` const they hand `<Carousel key>`, which is
254
+ * what makes this lifecycle theirs.
255
+ */
256
+ const gatedPerRender: boolean[] = [];
257
+ const responsive = {
258
+ desktop: { breakpoint: { max: 3000, min: DESKTOP_MIN_WIDTH }, items: 4 }
259
+ };
260
+
261
+ const Probe = ({ remountKey }: { remountKey: string }) => {
262
+ const ssr = useCarouselSsr({ id: 'strip', remountKey, responsive });
263
+ gatedPerRender.push(ssr.gateStyle !== null);
264
+ return null;
265
+ };
266
+
267
+ const { rerender } = render(<Probe remountKey="4-4-desktop" />);
268
+ expect(gatedPerRender).toEqual([true, false]);
269
+
270
+ rerender(<Probe remountKey="4-4-mobile" />);
271
+ expect(gatedPerRender).toEqual([true, false, true, false]);
272
+
273
+ // A re-render that does not change the key does not reopen it.
274
+ rerender(<Probe remountKey="4-4-mobile" />);
275
+ expect(gatedPerRender).toEqual([true, false, true, false, false]);
276
+ });
277
+ });
278
+
279
+ describe('deviceType is a constant, never the currentBreakpoint prop', () => {
280
+ /**
281
+ * Asserted through the width the library writes on each slide rather than
282
+ * through the prop, because the prop is only the mechanism. The block's
283
+ * default is 4 items on desktop and 1 on mobile; if `currentBreakpoint` were
284
+ * handed to `deviceType`, a mobile render would size its slides 100.0% and a
285
+ * desktop one 25.0%, and the two would disagree across hydration.
286
+ */
287
+ it.each(['desktop', 'tablet', 'mobile'])(
288
+ 'sizes slides from the desktop band at currentBreakpoint=%s',
289
+ (breakpoint) => {
290
+ const dom = serverDom(
291
+ <SliderBlock {...sliderProps({}, { currentBreakpoint: breakpoint })} />
292
+ );
293
+ expect(slides(dom).length).toBe(6);
294
+ slides(dom).forEach((item) => {
295
+ expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
296
+ });
297
+ }
298
+ );
299
+
300
+ it('survives a breakpoint the responsive map has no band for', () => {
301
+ /**
302
+ * `slider-block`'s map has `desktop` and `mobile` and nothing else. With
303
+ * `currentBreakpoint` passed as `deviceType`, a slide peek sends the
304
+ * library into `getPartialVisibilityGutter`, which reads
305
+ * `responsive['tablet'].paritialVisibilityGutter` with no null check and
306
+ * throws — a blank section, in the editor, on a tablet.
307
+ */
308
+ expect(() =>
309
+ serverDom(
310
+ <SliderBlock
311
+ {...sliderProps(
312
+ { 'slide-peek': { desktop: 40, mobile: 24 } },
313
+ { currentBreakpoint: 'tablet' }
314
+ )}
315
+ />
316
+ )
317
+ ).not.toThrow();
318
+ });
319
+
320
+ /**
321
+ * THE OTHER CALL SITE, and the one a maintainer is likelier to "fix": its
322
+ * `<Carousel key>` already carries `currentBreakpoint`, so handing the same
323
+ * prop to `deviceType` looks like tidying. Its map has three bands with
324
+ * distinct counts, so the desktop basis is the only one that can be right on
325
+ * the server whatever the client thinks it is looking at.
326
+ */
327
+ it.each(['desktop', 'tablet', 'mobile'])(
328
+ 'sizes the posts strip from the desktop band at currentBreakpoint=%s',
329
+ (breakpoint) => {
330
+ const dom = serverDom(
331
+ <PostsSliderSection
332
+ {...postsProps({ currentBreakpoint: breakpoint })}
333
+ />
334
+ );
335
+ expect(slides(dom).length).toBe(5);
336
+ slides(dom).forEach((item) => {
337
+ expect((item as HTMLElement).style.flex).toBe('1 0 25.0%');
338
+ });
339
+ }
340
+ );
341
+ });
342
+
343
+ describe('a carousel with no id stays off the SSR path', () => {
344
+ /**
345
+ * The width rule is scoped by the id, so without one it cannot be written —
346
+ * and opting into the library's SSR path WITHOUT that rule is worse than not
347
+ * opting in at all: the track is a flex item with an indefinite width, so
348
+ * the percentage basis collapses the strip. Two id-less sliders sharing a
349
+ * literal fallback would additionally restyle each other's slides with the
350
+ * wrong item count. Rendering nothing is the honest failure.
351
+ */
352
+ it('emits no slides, no gate attribute and no stylesheet', () => {
353
+ const withId = sliderProps();
354
+ const withoutId = {
355
+ ...withId,
356
+ // The same block in every other respect — six slides included — so a
357
+ // zero here can only come from the missing id.
358
+ block: { ...withId.block, id: '' }
359
+ };
360
+
361
+ expect(slides(serverDom(<SliderBlock {...withId} />)).length).toBe(6);
362
+
363
+ const dom = serverDom(<SliderBlock {...(withoutId as any)} />);
364
+
365
+ expect(slides(dom).length).toBe(0);
366
+ expect(dom.querySelector('[data-carousel-ssr]')).toBeNull();
367
+ expect(dom.querySelector('style')).toBeNull();
368
+ });
369
+ });
370
+
371
+ describe('the gated sheet switches where the responsive map switches', () => {
372
+ it('hands the 768-1023 band the mobile item count, not the desktop one', () => {
373
+ // The map used to say `desktop: { min: 768 }` while every other reader of
374
+ // this block drew the line at 1024. A sheet written against the old number
375
+ // would have applied the mobile count from 768 up while the mounted library
376
+ // applied the desktop count from 768 up — visible resize at hydration.
377
+ const sheet = serverDom(
378
+ <SliderBlock
379
+ {...sliderProps({ 'items-per-slide': { desktop: 4, mobile: 2 } })}
380
+ />
381
+ ).querySelector('style')!.textContent!;
382
+
383
+ expect(sheet).toContain(`@media (max-width:${DESKTOP_MIN_WIDTH - 1}px)`);
384
+ expect(sheet).toContain('flex:1 0 50.0% !important;');
385
+ expect(sheet).not.toContain('max-width:767px');
386
+ });
387
+ });
@@ -448,3 +448,92 @@ describe('VideoBlock', () => {
448
448
  });
449
449
  });
450
450
  });
451
+
452
+ /**
453
+ * The editor stores every video property PER BREAKPOINT — `{ desktop: false }`,
454
+ * not `false`. Two of the reads here compared that object against a scalar and
455
+ * so could never see the merchant's answer.
456
+ *
457
+ * It went unnoticed because nothing painted a video before hydration. With the
458
+ * slides now server-rendered (ZERO-5000), a cold load paints a full-width
459
+ * native control bar under the header on a video the merchant switched controls
460
+ * off for.
461
+ */
462
+ describe('properties the editor stores per breakpoint', () => {
463
+ const renderVideo = (properties: any, currentBreakpoint = 'desktop') =>
464
+ render(
465
+ <VideoBlock
466
+ block={
467
+ { value: 'https://cdn.example.com/lookbook.mp4', properties } as any
468
+ }
469
+ placeholderId="p1"
470
+ sectionId="s1"
471
+ isDesigner={false}
472
+ currentBreakpoint={currentBreakpoint}
473
+ />
474
+ ).container.querySelector('video') as HTMLVideoElement;
475
+
476
+ it('reads controls off the breakpoint, not off the object', () => {
477
+ // `properties.controls !== false` was true here: an object is not `false`.
478
+ expect(renderVideo({ controls: { desktop: false } }).controls).toBe(false);
479
+ });
480
+
481
+ it('still defaults controls on when the merchant set nothing', () => {
482
+ expect(renderVideo({}).controls).toBe(true);
483
+ expect(renderVideo({ controls: { desktop: true } }).controls).toBe(true);
484
+ });
485
+
486
+ it('lets a merchant hide controls on the phone and keep them on desktop', () => {
487
+ const authored = { controls: { desktop: true, mobile: false } };
488
+ expect(renderVideo(authored, 'desktop').controls).toBe(true);
489
+ expect(renderVideo(authored, 'mobile').controls).toBe(false);
490
+ });
491
+
492
+ it('keeps honouring a plain boolean, which is what older JSON holds', () => {
493
+ expect(renderVideo({ controls: false }).controls).toBe(false);
494
+ expect(renderVideo({ controls: true }).controls).toBe(true);
495
+ });
496
+
497
+ it('reaches a poster the merchant typed, instead of dropping it', () => {
498
+ // `typeof properties.poster === 'string'` is false for the object the
499
+ // editor writes, so the poster was stored, shown in the panel and lost.
500
+ const poster = renderVideo({
501
+ poster: { desktop: 'https://cdn.example.com/cover.jpg' }
502
+ }).poster;
503
+ expect(poster).toContain('cover.jpg');
504
+ });
505
+
506
+ it('falls back to the desktop poster on a breakpoint that authored none', () => {
507
+ expect(
508
+ renderVideo(
509
+ { poster: { desktop: 'https://cdn.example.com/cover.jpg' } },
510
+ 'mobile'
511
+ ).poster
512
+ ).toContain('cover.jpg');
513
+ });
514
+
515
+ it('sets no poster when there is none, rather than an empty one', () => {
516
+ expect(renderVideo({}).getAttribute('poster')).toBeNull();
517
+ expect(
518
+ renderVideo({ poster: { desktop: '' } }).getAttribute('poster')
519
+ ).toBeNull();
520
+ });
521
+
522
+ it('leaves autoplay, loop and muted exactly as they behave today', () => {
523
+ /**
524
+ * Their stored value is an object too, and an object is truthy — so the
525
+ * bug is what makes them true. Reading them correctly would switch
526
+ * autoplay and loop OFF on every composition already published, which is a
527
+ * separate decision. This locks the current behaviour so the change cannot
528
+ * arrive by accident alongside the two above.
529
+ */
530
+ const video = renderVideo({
531
+ autoplay: { desktop: false },
532
+ loop: { desktop: false },
533
+ muted: { desktop: false }
534
+ });
535
+
536
+ expect(video.autoplay).toBe(true);
537
+ expect(video.loop).toBe(true);
538
+ });
539
+ });