@akinon/pz-theme 2.0.127-rc.0 → 2.0.127
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 +7 -35
- package/package.json +4 -4
- package/src/blocks/__tests__/video-block.test.tsx +639 -24
- package/src/blocks/video-block.tsx +529 -45
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { act, render, screen } from '@testing-library/react';
|
|
3
3
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
4
|
import VideoBlock from '../video-block';
|
|
5
5
|
|
|
@@ -41,6 +41,105 @@ const renderBlock = (value: any, properties?: any, styles?: any) =>
|
|
|
41
41
|
/>
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Stand the test in a browser whose owner asked for less motion.
|
|
46
|
+
*
|
|
47
|
+
* `withViewport` cannot answer this one: it only ever matches a `max-width`
|
|
48
|
+
* query, which is exactly what keeps every other test in this file from arming
|
|
49
|
+
* reduced motion by accident.
|
|
50
|
+
*/
|
|
51
|
+
const withReducedMotion = (run: () => void) => {
|
|
52
|
+
const original = window.matchMedia;
|
|
53
|
+
window.matchMedia = ((query: string) => ({
|
|
54
|
+
matches: query.includes('prefers-reduced-motion'),
|
|
55
|
+
media: query,
|
|
56
|
+
onchange: null,
|
|
57
|
+
addListener: () => {},
|
|
58
|
+
removeListener: () => {},
|
|
59
|
+
addEventListener: () => {},
|
|
60
|
+
removeEventListener: () => {},
|
|
61
|
+
dispatchEvent: () => false
|
|
62
|
+
})) as unknown as typeof window.matchMedia;
|
|
63
|
+
try {
|
|
64
|
+
run();
|
|
65
|
+
} finally {
|
|
66
|
+
window.matchMedia = original;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A recording stand-in for IntersectionObserver.
|
|
72
|
+
*
|
|
73
|
+
* `jest/setup.ts` installs a NoopObserver so nothing crashes on mount, which
|
|
74
|
+
* also means an observer-driven feature is INERT unless the suite brings its
|
|
75
|
+
* own. This is the fake `listing-controls.test.tsx` already uses: it keeps the
|
|
76
|
+
* options it was built with, the elements it was handed and whether it was
|
|
77
|
+
* disconnected, so a test can ask all three.
|
|
78
|
+
*/
|
|
79
|
+
class MockObserver {
|
|
80
|
+
static instances: MockObserver[] = [];
|
|
81
|
+
callback: IntersectionObserverCallback;
|
|
82
|
+
options: IntersectionObserverInit | undefined;
|
|
83
|
+
elements: Element[] = [];
|
|
84
|
+
disconnected = false;
|
|
85
|
+
|
|
86
|
+
constructor(
|
|
87
|
+
callback: IntersectionObserverCallback,
|
|
88
|
+
options?: IntersectionObserverInit
|
|
89
|
+
) {
|
|
90
|
+
this.callback = callback;
|
|
91
|
+
this.options = options;
|
|
92
|
+
MockObserver.instances.push(this);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
observe(element: Element) {
|
|
96
|
+
this.elements.push(element);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
unobserve() {}
|
|
100
|
+
|
|
101
|
+
disconnect() {
|
|
102
|
+
this.disconnected = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
takeRecords() {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Deliver one entry, the way a real observer does when the page scrolls. */
|
|
110
|
+
fire(isIntersecting: boolean) {
|
|
111
|
+
act(() => {
|
|
112
|
+
this.callback(
|
|
113
|
+
[{ isIntersecting } as IntersectionObserverEntry],
|
|
114
|
+
this as unknown as IntersectionObserver
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
static get live() {
|
|
120
|
+
return MockObserver.instances.filter((instance) => !instance.disconnected);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The boxes the control sits in, named the way the source names them:
|
|
126
|
+
*
|
|
127
|
+
* overlay inset 0, holds the padding -> the right edge
|
|
128
|
+
* column flex column -> the bottom edge
|
|
129
|
+
* rail position: sticky -> stays reachable
|
|
130
|
+
* button
|
|
131
|
+
*
|
|
132
|
+
* The button itself carries none of the placement any more, so a test that
|
|
133
|
+
* reads `button.style.left` is reading the wrong node.
|
|
134
|
+
*/
|
|
135
|
+
const controlBoxes = (container: HTMLElement) => {
|
|
136
|
+
const button = container.querySelector('button') as HTMLButtonElement;
|
|
137
|
+
const rail = button.parentElement as HTMLElement;
|
|
138
|
+
const column = rail.parentElement as HTMLElement;
|
|
139
|
+
const overlay = column.parentElement as HTMLElement;
|
|
140
|
+
return { button, rail, column, overlay };
|
|
141
|
+
};
|
|
142
|
+
|
|
44
143
|
describe('VideoBlock', () => {
|
|
45
144
|
/**
|
|
46
145
|
* A designer's Corner Radius lands on the block's `[data-block-id]` box, and
|
|
@@ -814,18 +913,18 @@ describe('VideoBlock', () => {
|
|
|
814
913
|
expect(video.pause).toHaveBeenCalledTimes(1);
|
|
815
914
|
});
|
|
816
915
|
|
|
817
|
-
it('stacks above a sibling content overlay
|
|
916
|
+
it('stacks above a sibling content overlay, so its clicks are not swallowed', () => {
|
|
818
917
|
// A title/CTA content block placed over the video is a full-size
|
|
819
918
|
// `position: absolute; z-index: 1` box (see pz-theme's group content
|
|
820
|
-
// pattern) — its transparent area still captures pointer events.
|
|
821
|
-
//
|
|
822
|
-
//
|
|
919
|
+
// pattern) — its transparent area still captures pointer events. The
|
|
920
|
+
// z-index belongs on the control LAYER now, because that is the box the
|
|
921
|
+
// shopper's click has to land in before it can reach a button.
|
|
823
922
|
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
824
923
|
controls: false,
|
|
825
924
|
customPlayPause: true
|
|
826
925
|
});
|
|
827
|
-
const
|
|
828
|
-
expect(Number(
|
|
926
|
+
const { overlay } = controlBoxes(container);
|
|
927
|
+
expect(Number(overlay.style.zIndex)).toBeGreaterThan(1);
|
|
829
928
|
});
|
|
830
929
|
|
|
831
930
|
it('matches the design: a 20x20 button, not an oversized icon', () => {
|
|
@@ -839,20 +938,40 @@ describe('VideoBlock', () => {
|
|
|
839
938
|
});
|
|
840
939
|
|
|
841
940
|
it('draws the pause icon as two 1x8 bars', () => {
|
|
941
|
+
// One path with the gap cut out of it, not two rects: at 8px the glyph
|
|
942
|
+
// has to read as a hairline, the way the outlined triangle beside it
|
|
943
|
+
// does, and a single path is how the reference storefront draws it.
|
|
944
|
+
// `playWhenVisible: false` is what puts this clip in the playing state
|
|
945
|
+
// on the first render: it keeps the block out of ambient mode, so the
|
|
946
|
+
// element still carries the autoplay attribute and the control is seeded
|
|
947
|
+
// to match it. An ambient clip is honestly stopped until the observer
|
|
948
|
+
// starts it, and draws the play glyph until then.
|
|
842
949
|
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
843
950
|
controls: false,
|
|
844
951
|
customPlayPause: true,
|
|
845
|
-
autoplay: true
|
|
952
|
+
autoplay: true,
|
|
953
|
+
playWhenVisible: false
|
|
846
954
|
});
|
|
847
|
-
const
|
|
848
|
-
expect(
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
955
|
+
const paths = container.querySelectorAll('button svg path');
|
|
956
|
+
expect(paths).toHaveLength(1);
|
|
957
|
+
expect(paths[0].getAttribute('d')).toBe('M1.5 8h-1V0h1zm6 0h-1V0h1z');
|
|
958
|
+
expect(container.querySelector('button svg')).toHaveAttribute(
|
|
959
|
+
'viewBox',
|
|
960
|
+
'0 0 8 8'
|
|
961
|
+
);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it('draws the play icon as an outlined triangle, not a solid wedge', () => {
|
|
965
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
966
|
+
controls: false,
|
|
967
|
+
customPlayPause: true
|
|
852
968
|
});
|
|
969
|
+
const paths = container.querySelectorAll('button svg path');
|
|
970
|
+
expect(paths).toHaveLength(1);
|
|
971
|
+
expect(paths[0].getAttribute('d')).toBe('M7 4 1 8V0zM2 6.13 5.197 4 2 1.868z');
|
|
853
972
|
});
|
|
854
973
|
|
|
855
|
-
it('sits bottom-
|
|
974
|
+
it('sits in the bottom-right corner, 24px in from both edges, on desktop', () => {
|
|
856
975
|
const { container } = render(
|
|
857
976
|
<VideoBlock
|
|
858
977
|
block={
|
|
@@ -867,14 +986,14 @@ describe('VideoBlock', () => {
|
|
|
867
986
|
currentBreakpoint="desktop"
|
|
868
987
|
/>
|
|
869
988
|
);
|
|
870
|
-
const
|
|
871
|
-
expect(
|
|
872
|
-
expect(
|
|
873
|
-
expect(
|
|
874
|
-
expect(
|
|
989
|
+
const { overlay, column, rail } = controlBoxes(container);
|
|
990
|
+
expect(overlay.style.justifyContent).toBe('flex-end'); // the right edge
|
|
991
|
+
expect(column.style.justifyContent).toBe('flex-end'); // the bottom edge
|
|
992
|
+
expect(overlay.style.padding).toBe('24px');
|
|
993
|
+
expect(rail.style.bottom).toBe('24px');
|
|
875
994
|
});
|
|
876
995
|
|
|
877
|
-
it('
|
|
996
|
+
it('keeps that same bottom-right corner on mobile, at a tighter 18px', () => {
|
|
878
997
|
const { container } = render(
|
|
879
998
|
<VideoBlock
|
|
880
999
|
block={
|
|
@@ -889,12 +1008,508 @@ describe('VideoBlock', () => {
|
|
|
889
1008
|
currentBreakpoint="mobile"
|
|
890
1009
|
/>
|
|
891
1010
|
);
|
|
1011
|
+
const { overlay, column, rail } = controlBoxes(container);
|
|
1012
|
+
expect(overlay.style.justifyContent).toBe('flex-end');
|
|
1013
|
+
expect(column.style.justifyContent).toBe('flex-end');
|
|
1014
|
+
expect(overlay.style.padding).toBe('18px');
|
|
1015
|
+
expect(rail.style.bottom).toBe('18px');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
/*
|
|
1019
|
+
* The three names the panel writes rules against. A part the renderer does
|
|
1020
|
+
* not stamp is a whole group of controls that save and do nothing, so the
|
|
1021
|
+
* names are asserted here as well as by the cross-repo parity test — that
|
|
1022
|
+
* one is skipped wherever pz-dynamic-widget is not checked out, which is
|
|
1023
|
+
* every CI run.
|
|
1024
|
+
*/
|
|
1025
|
+
describe('a glyph the brand supplied', () => {
|
|
1026
|
+
const MARK =
|
|
1027
|
+
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=';
|
|
1028
|
+
|
|
1029
|
+
it('replaces the built-in one for the state it was uploaded against', () => {
|
|
1030
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1031
|
+
controls: false,
|
|
1032
|
+
customPlayPause: true,
|
|
1033
|
+
playIcon: MARK
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// The paused clip draws the authored mark, and the built-in triangle
|
|
1037
|
+
// is gone rather than sitting behind it.
|
|
1038
|
+
const masked = container.querySelector('button span') as HTMLElement;
|
|
1039
|
+
expect(masked).toBeInTheDocument();
|
|
1040
|
+
expect(masked.style.maskImage).toContain(MARK);
|
|
1041
|
+
expect(container.querySelector('button svg')).not.toBeInTheDocument();
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
it('is painted by the panel, not by whatever colours the file carries', () => {
|
|
1045
|
+
// A mask filled with currentColor, because the button's colour lives in
|
|
1046
|
+
// generated part CSS the renderer cannot read — baking a colour into
|
|
1047
|
+
// the SVG the way the icon block does would ignore the panel entirely.
|
|
1048
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1049
|
+
controls: false,
|
|
1050
|
+
customPlayPause: true,
|
|
1051
|
+
playIcon: MARK
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
const masked = container.querySelector('button span') as HTMLElement;
|
|
1055
|
+
expect(masked.style.backgroundColor).toBe('currentColor');
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it('leaves the other states on the marks that ship with the block', () => {
|
|
1059
|
+
// Only Play was uploaded, so Pause is still ours — the two states are
|
|
1060
|
+
// separate fields precisely so they can differ.
|
|
1061
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1062
|
+
controls: false,
|
|
1063
|
+
customPlayPause: true,
|
|
1064
|
+
playWhenVisible: false,
|
|
1065
|
+
autoplay: true,
|
|
1066
|
+
playIcon: MARK
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
expect(container.querySelector('button svg')).toBeInTheDocument();
|
|
1070
|
+
expect(container.querySelector('button span')).not.toBeInTheDocument();
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it('keeps the built-in mark when the field was left empty', () => {
|
|
1074
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1075
|
+
controls: false,
|
|
1076
|
+
customPlayPause: true,
|
|
1077
|
+
playIcon: ''
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
expect(container.querySelector('button svg')).toBeInTheDocument();
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('names the parts the panel styles: the overlay, the rail and the buttons', () => {
|
|
1085
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1086
|
+
controls: false,
|
|
1087
|
+
customPlayPause: true,
|
|
1088
|
+
soundToggle: true
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
expect(container.querySelector('[data-part="controls"]')).toBeInTheDocument();
|
|
1092
|
+
expect(container.querySelector('[data-part="control-rail"]')).toBeInTheDocument();
|
|
1093
|
+
// Both buttons carry the SAME name: they are a matched pair and a brand
|
|
1094
|
+
// restyles them together.
|
|
1095
|
+
expect(container.querySelectorAll('[data-part="control-button"]')).toHaveLength(2);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
/*
|
|
1099
|
+
* The wrapper's handler is a REACT one — `WithDesignerFeatures` puts
|
|
1100
|
+
* `onClick` on the `[data-block-id]` div — so these two stand the block
|
|
1101
|
+
* inside a React `onClick` rather than adding a native listener to the RTL
|
|
1102
|
+
* container. React attaches its own listener at the root container, so a
|
|
1103
|
+
* native listener there fires whatever the synthetic event does, and the
|
|
1104
|
+
* test would be measuring the harness instead of the block.
|
|
1105
|
+
*/
|
|
1106
|
+
const renderInsideClickableWrapper = (isDesigner: boolean) => {
|
|
1107
|
+
const onWrapperClick = jest.fn();
|
|
1108
|
+
const { container } = render(
|
|
1109
|
+
<div onClick={onWrapperClick}>
|
|
1110
|
+
<VideoBlock
|
|
1111
|
+
block={
|
|
1112
|
+
{
|
|
1113
|
+
value: 'https://cdn.example.com/clip.mp4',
|
|
1114
|
+
properties: { controls: false, customPlayPause: true }
|
|
1115
|
+
} as any
|
|
1116
|
+
}
|
|
1117
|
+
placeholderId="p1"
|
|
1118
|
+
sectionId="s1"
|
|
1119
|
+
isDesigner={isDesigner}
|
|
1120
|
+
/>
|
|
1121
|
+
</div>
|
|
1122
|
+
);
|
|
1123
|
+
return { onWrapperClick, container };
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
it('keeps the click off the block, so a video with an Action does not navigate on pause', () => {
|
|
1127
|
+
const { onWrapperClick, container } = renderInsideClickableWrapper(false);
|
|
1128
|
+
(container.querySelector('button') as HTMLButtonElement).click();
|
|
1129
|
+
expect(onWrapperClick).not.toHaveBeenCalled();
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('lets the click through inside the designer, so the canvas can select the part', () => {
|
|
1133
|
+
// The canvas resolves which part was clicked from one bubble handler on
|
|
1134
|
+
// the block wrapper. Swallowing the event there selected nothing at all —
|
|
1135
|
+
// not the part, not even the block.
|
|
1136
|
+
const { onWrapperClick, container } = renderInsideClickableWrapper(true);
|
|
1137
|
+
(container.querySelector('button') as HTMLButtonElement).click();
|
|
1138
|
+
expect(onWrapperClick).toHaveBeenCalled();
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it('stays reachable on a portrait clip taller than the window', () => {
|
|
1142
|
+
// At 749px wide a 2:3 clip is 1123px tall — taller than most laptop
|
|
1143
|
+
// viewports — so a control parked at the bottom of the block is off
|
|
1144
|
+
// screen for the whole time the clip is being watched.
|
|
1145
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1146
|
+
controls: false,
|
|
1147
|
+
customPlayPause: true
|
|
1148
|
+
});
|
|
1149
|
+
const { overlay, rail } = controlBoxes(container);
|
|
1150
|
+
expect(rail.style.position).toBe('sticky');
|
|
1151
|
+
expect(rail.style.bottom).toBe(overlay.style.padding);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it('lets a click reach whatever sits under the player, except on the button', () => {
|
|
1155
|
+
const { container } = renderBlock('https://cdn.example.com/clip.mp4', {
|
|
1156
|
+
controls: false,
|
|
1157
|
+
customPlayPause: true
|
|
1158
|
+
});
|
|
1159
|
+
const { overlay, column, rail, button } = controlBoxes(container);
|
|
1160
|
+
expect(overlay.style.pointerEvents).toBe('none');
|
|
1161
|
+
expect(column.style.pointerEvents).toBe('none');
|
|
1162
|
+
expect(rail.style.pointerEvents).toBe('none');
|
|
1163
|
+
expect(button.style.pointerEvents).toBe('auto');
|
|
1164
|
+
});
|
|
1165
|
+
describe('the sound button beside it', () => {
|
|
1166
|
+
const renderControls = (properties: any) =>
|
|
1167
|
+
render(
|
|
1168
|
+
<VideoBlock
|
|
1169
|
+
block={
|
|
1170
|
+
{
|
|
1171
|
+
value: 'https://cdn.example.com/clip.mp4',
|
|
1172
|
+
properties: {
|
|
1173
|
+
controls: false,
|
|
1174
|
+
customPlayPause: true,
|
|
1175
|
+
muted: true,
|
|
1176
|
+
...properties
|
|
1177
|
+
}
|
|
1178
|
+
} as any
|
|
1179
|
+
}
|
|
1180
|
+
placeholderId="p1"
|
|
1181
|
+
sectionId="s1"
|
|
1182
|
+
isDesigner={false}
|
|
1183
|
+
currentBreakpoint="desktop"
|
|
1184
|
+
/>
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
it('is absent unless the merchant asks for it, because an ambient clip is silent furniture', () => {
|
|
1188
|
+
const { container } = renderControls({});
|
|
1189
|
+
expect(container.querySelectorAll('button')).toHaveLength(1);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('cannot appear on its own, without the play/pause button it rides with', () => {
|
|
1193
|
+
const { container } = renderControls({
|
|
1194
|
+
customPlayPause: false,
|
|
1195
|
+
soundToggle: true
|
|
1196
|
+
});
|
|
1197
|
+
expect(container.querySelectorAll('button')).toHaveLength(0);
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it('appears beside play/pause when both are turned on', () => {
|
|
1201
|
+
const { container } = renderControls({ soundToggle: true });
|
|
1202
|
+
expect(container.querySelectorAll('button')).toHaveLength(2);
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it('is the left of the two, so play/pause keeps the corner it always had', () => {
|
|
1206
|
+
const { container } = renderControls({ soundToggle: true });
|
|
1207
|
+
const [first, second] = Array.from(container.querySelectorAll('button'));
|
|
1208
|
+
expect(first).toHaveAttribute('aria-label', 'Unmute video');
|
|
1209
|
+
expect(second).toHaveAttribute('aria-label', 'Play video');
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it('actually unmutes the clip when the shopper presses it, and says so', () => {
|
|
1213
|
+
const { container } = renderControls({ soundToggle: true });
|
|
1214
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
1215
|
+
const sound = container.querySelectorAll('button')[0] as HTMLButtonElement;
|
|
1216
|
+
|
|
1217
|
+
expect(video.muted).toBe(true);
|
|
1218
|
+
expect(sound).toHaveAttribute('aria-pressed', 'false');
|
|
1219
|
+
|
|
1220
|
+
act(() => {
|
|
1221
|
+
sound.click();
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
expect(video.muted).toBe(false);
|
|
1225
|
+
expect(sound).toHaveAttribute('aria-label', 'Mute video');
|
|
1226
|
+
expect(sound).toHaveAttribute('aria-pressed', 'true');
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* PLAYING ONLY WHILE THE CLIP IS ON SCREEN.
|
|
1232
|
+
*
|
|
1233
|
+
* An `autoPlay` attribute starts a clip wherever it sits on the page, so a
|
|
1234
|
+
* homepage with three of them decodes three films at once. Everything below
|
|
1235
|
+
* needs the recording observer: the one `jest/setup.ts` installs does
|
|
1236
|
+
* nothing, so without this harness the whole feature is invisible to a test.
|
|
1237
|
+
*/
|
|
1238
|
+
describe('a clip that plays by itself', () => {
|
|
1239
|
+
const originalObserver = window.IntersectionObserver;
|
|
1240
|
+
const originalPlay = window.HTMLMediaElement.prototype.play;
|
|
1241
|
+
const originalPause = window.HTMLMediaElement.prototype.pause;
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* The property set that makes a clip ambient.
|
|
1245
|
+
*
|
|
1246
|
+
* `controls: false` is not decoration. A player showing the browser's own
|
|
1247
|
+
* control bar is a deliberate watch and the viewport never touches it, so
|
|
1248
|
+
* leaving it out here would describe a block the observer is not supposed
|
|
1249
|
+
* to drive at all.
|
|
1250
|
+
*/
|
|
1251
|
+
const ambient = {
|
|
1252
|
+
controls: false,
|
|
1253
|
+
autoplay: true,
|
|
1254
|
+
muted: true,
|
|
1255
|
+
loop: true
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
let play: jest.Mock;
|
|
1259
|
+
let pause: jest.Mock;
|
|
1260
|
+
|
|
1261
|
+
beforeEach(() => {
|
|
1262
|
+
MockObserver.instances = [];
|
|
1263
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
1264
|
+
MockObserver;
|
|
1265
|
+
play = jest.fn(() => Promise.resolve());
|
|
1266
|
+
pause = jest.fn();
|
|
1267
|
+
window.HTMLMediaElement.prototype.play =
|
|
1268
|
+
play as unknown as typeof originalPlay;
|
|
1269
|
+
window.HTMLMediaElement.prototype.pause = pause;
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
afterEach(() => {
|
|
1273
|
+
(window as unknown as Record<string, unknown>).IntersectionObserver =
|
|
1274
|
+
originalObserver;
|
|
1275
|
+
window.HTMLMediaElement.prototype.play = originalPlay;
|
|
1276
|
+
window.HTMLMediaElement.prototype.pause = originalPause;
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
const renderPlayer = (properties: any, isDesigner = false) =>
|
|
1280
|
+
render(
|
|
1281
|
+
<VideoBlock
|
|
1282
|
+
block={
|
|
1283
|
+
{ value: 'https://cdn.example.com/clip.mp4', properties } as any
|
|
1284
|
+
}
|
|
1285
|
+
placeholderId="p1"
|
|
1286
|
+
sectionId="s1"
|
|
1287
|
+
isDesigner={isDesigner}
|
|
1288
|
+
currentBreakpoint="desktop"
|
|
1289
|
+
/>
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
it('starts as soon as a tenth of it has scrolled into view', () => {
|
|
1293
|
+
const { container } = renderPlayer({ ...ambient });
|
|
1294
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
1295
|
+
|
|
1296
|
+
expect(MockObserver.instances).toHaveLength(1);
|
|
1297
|
+
expect(MockObserver.instances[0].options?.threshold).toBe(0.1);
|
|
1298
|
+
expect(MockObserver.instances[0].elements).toEqual([video]);
|
|
1299
|
+
|
|
1300
|
+
MockObserver.instances[0].fire(true);
|
|
1301
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
it('stops again when the shopper scrolls it off the screen', () => {
|
|
1305
|
+
renderPlayer({ ...ambient });
|
|
1306
|
+
|
|
1307
|
+
MockObserver.instances[0].fire(true);
|
|
1308
|
+
MockObserver.instances[0].fire(false);
|
|
1309
|
+
|
|
1310
|
+
expect(pause).toHaveBeenCalledTimes(1);
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1313
|
+
it('picks up where it stopped instead of rewinding to the start', () => {
|
|
1314
|
+
const { container } = renderPlayer({ ...ambient });
|
|
1315
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
1316
|
+
|
|
1317
|
+
// jsdom's `currentTime` cannot be read back after a write, so the
|
|
1318
|
+
// property is swapped for one that records every seek attempt.
|
|
1319
|
+
const seeks: number[] = [];
|
|
1320
|
+
Object.defineProperty(video, 'currentTime', {
|
|
1321
|
+
configurable: true,
|
|
1322
|
+
get: () => 12,
|
|
1323
|
+
set: (value: number) => seeks.push(value)
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
MockObserver.instances[0].fire(true);
|
|
1327
|
+
MockObserver.instances[0].fire(false);
|
|
1328
|
+
MockObserver.instances[0].fire(true);
|
|
1329
|
+
|
|
1330
|
+
expect(seeks).toEqual([]);
|
|
1331
|
+
expect(play).toHaveBeenCalledTimes(2);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('plays again after a hand pause once the shopper scrolls back to it', () => {
|
|
1335
|
+
// Measured on the reference storefront rather than overlooked: pause by
|
|
1336
|
+
// hand, scroll away, scroll back, and the clip is running again. The
|
|
1337
|
+
// viewport is authoritative and the button is an override that lasts
|
|
1338
|
+
// only while the clip is on screen.
|
|
1339
|
+
const { container } = renderPlayer({
|
|
1340
|
+
...ambient,
|
|
1341
|
+
controls: false,
|
|
1342
|
+
customPlayPause: true
|
|
1343
|
+
});
|
|
1344
|
+
const video = container.querySelector('video') as HTMLVideoElement;
|
|
892
1345
|
const button = container.querySelector('button') as HTMLButtonElement;
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
expect(
|
|
896
|
-
|
|
1346
|
+
|
|
1347
|
+
MockObserver.instances[0].fire(true);
|
|
1348
|
+
expect(play).toHaveBeenCalledTimes(1);
|
|
1349
|
+
|
|
1350
|
+
Object.defineProperty(video, 'paused', {
|
|
1351
|
+
value: false,
|
|
1352
|
+
configurable: true
|
|
1353
|
+
});
|
|
1354
|
+
act(() => {
|
|
1355
|
+
button.click();
|
|
1356
|
+
});
|
|
1357
|
+
expect(pause).toHaveBeenCalledTimes(1);
|
|
1358
|
+
|
|
1359
|
+
MockObserver.instances[0].fire(false);
|
|
1360
|
+
MockObserver.instances[0].fire(true);
|
|
1361
|
+
|
|
1362
|
+
expect(play).toHaveBeenCalledTimes(2);
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it('stops watching the viewport once the block leaves the page', () => {
|
|
1366
|
+
const { unmount } = renderPlayer({ ...ambient });
|
|
1367
|
+
expect(MockObserver.live).toHaveLength(1);
|
|
1368
|
+
|
|
1369
|
+
unmount();
|
|
1370
|
+
|
|
1371
|
+
expect(MockObserver.live).toHaveLength(0);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('leaves a clip the shopper has to press play on entirely alone', () => {
|
|
1375
|
+
renderPlayer({ muted: true, loop: true });
|
|
1376
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
/*
|
|
1380
|
+
* The shape the editor ACTUALLY writes, which is the only shape almost
|
|
1381
|
+
* every published block is in: `getDefaultBlockProperties` stamps
|
|
1382
|
+
* `{ desktop: <default> }` for every property, so a merchant who touched
|
|
1383
|
+
* nothing has `controls: {desktop:true}` and `autoplay: {desktop:false}`.
|
|
1384
|
+
* `autoplay` is read raw and an object is truthy, so the clip LOOKS
|
|
1385
|
+
* ambient to that read — `controls` is the gate that saves it. Written
|
|
1386
|
+
* with plain booleans, this whole describe block would pass while every
|
|
1387
|
+
* published video on the storefront quietly became viewport-driven and
|
|
1388
|
+
* undid shoppers' manual pauses.
|
|
1389
|
+
*/
|
|
1390
|
+
it('leaves alone the video every merchant already published, controls and all', () => {
|
|
1391
|
+
renderPlayer({
|
|
1392
|
+
controls: { desktop: true },
|
|
1393
|
+
autoplay: { desktop: false },
|
|
1394
|
+
muted: { desktop: true }
|
|
1395
|
+
});
|
|
1396
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it('drives the ambient clip the editor writes per breakpoint, object shape and all', () => {
|
|
1400
|
+
renderPlayer({
|
|
1401
|
+
controls: { desktop: false },
|
|
1402
|
+
customPlayPause: { desktop: true },
|
|
1403
|
+
autoplay: { desktop: true },
|
|
1404
|
+
muted: { desktop: true }
|
|
1405
|
+
});
|
|
1406
|
+
expect(MockObserver.instances).toHaveLength(1);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it('plays from the moment the page loads when the merchant turned Play when visible off', () => {
|
|
1410
|
+
const { container } = renderPlayer({
|
|
1411
|
+
...ambient,
|
|
1412
|
+
playWhenVisible: false
|
|
1413
|
+
});
|
|
1414
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
1415
|
+
expect(container.querySelector('video')).toHaveAttribute('autoplay');
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
it('is not driven by the viewport inside the editor canvas', () => {
|
|
1419
|
+
renderPlayer({ ...ambient }, true);
|
|
1420
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
describe('a shopper who asked for less motion', () => {
|
|
1424
|
+
it('gets the clip held still, with nothing watching to restart it', () => {
|
|
1425
|
+
withReducedMotion(() => {
|
|
1426
|
+
renderPlayer({ ...ambient });
|
|
1427
|
+
expect(MockObserver.instances).toHaveLength(0);
|
|
1428
|
+
expect(pause).toHaveBeenCalledTimes(1);
|
|
1429
|
+
});
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('can still press play, because the file was not left unfetchable', () => {
|
|
1433
|
+
// The element ships `preload="none"` waiting for an observer that is
|
|
1434
|
+
// never armed here, so standing down has to release it too.
|
|
1435
|
+
withReducedMotion(() => {
|
|
1436
|
+
const { container } = renderPlayer({ ...ambient });
|
|
1437
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1438
|
+
'preload',
|
|
1439
|
+
'metadata'
|
|
1440
|
+
);
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
897
1443
|
});
|
|
1444
|
+
|
|
1445
|
+
describe('how much of the file is fetched before anyone asks for it', () => {
|
|
1446
|
+
it('fetches nothing at all until an ambient clip reaches the viewport', () => {
|
|
1447
|
+
const { container } = renderPlayer({ ...ambient });
|
|
1448
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1449
|
+
'preload',
|
|
1450
|
+
'none'
|
|
1451
|
+
);
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
it('ships that same nothing in the server HTML, so the player hydrates', () => {
|
|
1455
|
+
// Server and first client render have to agree: if the attribute
|
|
1456
|
+
// differed the element would be rebuilt rather than adopted, and the
|
|
1457
|
+
// shopper would pay for the swap.
|
|
1458
|
+
const html = renderToStaticMarkup(
|
|
1459
|
+
<VideoBlock
|
|
1460
|
+
block={
|
|
1461
|
+
{
|
|
1462
|
+
value: 'https://cdn.example.com/clip.mp4',
|
|
1463
|
+
properties: { ...ambient }
|
|
1464
|
+
} as any
|
|
1465
|
+
}
|
|
1466
|
+
placeholderId="p1"
|
|
1467
|
+
sectionId="s1"
|
|
1468
|
+
isDesigner={false}
|
|
1469
|
+
/>
|
|
1470
|
+
);
|
|
1471
|
+
expect(html).toContain('preload="none"');
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('fetches the amount the merchant authored once the clip has been seen', () => {
|
|
1475
|
+
const { container } = renderPlayer({ ...ambient, preload: 'auto' });
|
|
1476
|
+
MockObserver.instances[0].fire(true);
|
|
1477
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1478
|
+
'preload',
|
|
1479
|
+
'auto'
|
|
1480
|
+
);
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
it('fetches that amount straight away when the clip never plays by itself', () => {
|
|
1484
|
+
const { container } = renderPlayer({ muted: true, preload: 'auto' });
|
|
1485
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1486
|
+
'preload',
|
|
1487
|
+
'auto'
|
|
1488
|
+
);
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
it('asks for metadata only when the merchant authored nothing', () => {
|
|
1492
|
+
const { container } = renderPlayer({});
|
|
1493
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1494
|
+
'preload',
|
|
1495
|
+
'metadata'
|
|
1496
|
+
);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it('loads normally in a browser that has no IntersectionObserver at all', () => {
|
|
1500
|
+
// Without this the clip would sit on `preload="none"` forever, waiting
|
|
1501
|
+
// for an observer the browser cannot build.
|
|
1502
|
+
delete (window as unknown as Record<string, unknown>)
|
|
1503
|
+
.IntersectionObserver;
|
|
1504
|
+
const { container } = renderPlayer({ ...ambient });
|
|
1505
|
+
expect(container.querySelector('video')).toHaveAttribute(
|
|
1506
|
+
'preload',
|
|
1507
|
+
'metadata'
|
|
1508
|
+
);
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
});
|
|
1512
|
+
|
|
898
1513
|
});
|
|
899
1514
|
});
|
|
900
1515
|
|