@classic-homes/theme-svelte 1.21.0 → 1.22.1

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 (79) hide show
  1. package/dist/lib/components/Alert.svelte +8 -10
  2. package/dist/lib/components/DataPanel.svelte +19 -5
  3. package/dist/lib/components/HeaderSearch.svelte +18 -10
  4. package/dist/lib/components/Skeleton.svelte.d.ts +1 -1
  5. package/dist/lib/components/layout/AppShell.svelte +1 -1
  6. package/dist/lib/components/layout/DashboardLayout.svelte +15 -6
  7. package/dist/lib/components/layout/Footer.svelte +6 -6
  8. package/dist/lib/components/layout/FormPageLayout.svelte +4 -3
  9. package/dist/lib/components/layout/Sidebar.svelte +25 -21
  10. package/dist/lib/components/media/Image.svelte.d.ts +1 -1
  11. package/dist/lib/components/media/Lightbox.svelte +13 -9
  12. package/dist/lib/components/media/MediaCarousel.svelte +5 -0
  13. package/dist/lib/components/media/MediaGrid.svelte +4 -1
  14. package/dist/lib/components/notifications/NotificationDialog.svelte +8 -5
  15. package/dist/lib/components/notifications/NotificationItem.svelte +16 -6
  16. package/dist/lib/components/studio/InsertPalette.svelte +4 -1
  17. package/dist/lib/components/studio/StudioCanvas.svelte +23 -2
  18. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +5 -1
  19. package/dist/lib/components/studio/StudioCanvasItem.svelte +31 -4
  20. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +7 -0
  21. package/dist/lib/composables/useFocusTrap.svelte.js +9 -2
  22. package/dist/lib/notices/sanitize.js +10 -3
  23. package/dist/lib/notices/store.d.ts +3 -3
  24. package/dist/lib/notices/store.js +14 -2
  25. package/dist/lib/notices/types.d.ts +8 -8
  26. package/dist/lib/notices/types.js +5 -1
  27. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  28. package/dist/lib/stores/sidebar.svelte.js +61 -26
  29. package/dist/lib/utils/media.js +4 -3
  30. package/dist/lib/utils/url.d.ts +10 -0
  31. package/dist/lib/utils/url.js +21 -0
  32. package/dist/lib/utils.js +14 -1
  33. package/package.json +5 -4
  34. package/dist/__tests__/FileUpload.test.js +0 -45
  35. package/dist/__tests__/a11y/Alert.a11y.test.js +0 -84
  36. package/dist/__tests__/a11y/AlertDialog.a11y.test.js +0 -126
  37. package/dist/__tests__/a11y/Button.a11y.test.js +0 -66
  38. package/dist/__tests__/a11y/Card.a11y.test.js +0 -77
  39. package/dist/__tests__/a11y/Combobox.a11y.test.js +0 -209
  40. package/dist/__tests__/a11y/Dialog.a11y.test.js +0 -103
  41. package/dist/__tests__/a11y/DropdownMenu.a11y.test.js +0 -132
  42. package/dist/__tests__/a11y/FileUpload.a11y.test.js +0 -158
  43. package/dist/__tests__/a11y/FilterToolbar.a11y.test.js +0 -62
  44. package/dist/__tests__/a11y/Icon.a11y.test.js +0 -119
  45. package/dist/__tests__/a11y/Input.a11y.test.js +0 -96
  46. package/dist/__tests__/a11y/LiveRegion.a11y.test.js +0 -102
  47. package/dist/__tests__/a11y/Media.a11y.test.js +0 -145
  48. package/dist/__tests__/a11y/PublicLayout.a11y.test.js +0 -139
  49. package/dist/__tests__/a11y/SegmentedControl.a11y.test.js +0 -50
  50. package/dist/__tests__/a11y/Switch.a11y.test.js +0 -95
  51. package/dist/__tests__/a11y/Tabs.a11y.test.js +0 -116
  52. package/dist/__tests__/a11y/Textarea.a11y.test.js +0 -111
  53. package/dist/__tests__/a11y/Toolbar.a11y.test.js +0 -31
  54. package/dist/__tests__/a11y/ViewToggle.a11y.test.js +0 -40
  55. package/dist/__tests__/action-header.test.js +0 -150
  56. package/dist/__tests__/alert-dialog-dismiss.test.js +0 -54
  57. package/dist/__tests__/code-field.test.js +0 -65
  58. package/dist/__tests__/control-text-color.test.js +0 -29
  59. package/dist/__tests__/data-panel-card-gestures.test.js +0 -138
  60. package/dist/__tests__/data-panel-viewport.test.js +0 -45
  61. package/dist/__tests__/dropdown-trigger.test.js +0 -39
  62. package/dist/__tests__/header-search.test.js +0 -90
  63. package/dist/__tests__/media-components.test.js +0 -206
  64. package/dist/__tests__/media-hls.test.js +0 -273
  65. package/dist/__tests__/media-matterport.test.js +0 -296
  66. package/dist/__tests__/media-players.test.js +0 -244
  67. package/dist/__tests__/media-scrubber.test.js +0 -44
  68. package/dist/__tests__/media-utils.test.js +0 -427
  69. package/dist/__tests__/nav-access.test.js +0 -145
  70. package/dist/__tests__/page-header.test.js +0 -36
  71. package/dist/__tests__/public-layout-drawer.test.js +0 -116
  72. package/dist/__tests__/setup.js +0 -56
  73. package/dist/__tests__/sidebar-collapse.test.js +0 -58
  74. package/dist/__tests__/sidebar-docs-parity.test.js +0 -134
  75. package/dist/__tests__/sidebar-expansion.test.js +0 -59
  76. package/dist/__tests__/sidebar-items.test.js +0 -59
  77. package/dist/__tests__/sidebar-mobile.test.js +0 -86
  78. package/dist/__tests__/studio-canvas-item-reveal.test.js +0 -50
  79. package/dist/__tests__/studio.test.js +0 -240
@@ -1,90 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor, within } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import { axe } from 'vitest-axe';
5
- import HeaderSearch from '../lib/components/HeaderSearch.svelte';
6
- const groups = [
7
- {
8
- title: 'Pages',
9
- items: [
10
- { id: 'p1', title: 'Dashboard', href: '/dashboard', icon: 'home' },
11
- { id: 'p2', title: 'Documents', href: '/documents' },
12
- ],
13
- },
14
- {
15
- title: 'Selections',
16
- items: [
17
- { id: 's1', title: 'Quartz countertop', description: 'Kitchen', category: 'Selection' },
18
- ],
19
- },
20
- ];
21
- async function openSearch(props = {}) {
22
- // A dialog left open by the previous test can leave pointer-events: none on body.
23
- const user = userEvent.setup({ pointerEventsCheck: 0 });
24
- const result = render(HeaderSearch, { props: { results: groups, ...props } });
25
- await user.click(screen.getByRole('button', { name: 'Search' }));
26
- const input = await screen.findByRole('combobox');
27
- return { user, input, ...result };
28
- }
29
- describe('HeaderSearch', () => {
30
- it('renders a heading for each result group', async () => {
31
- await openSearch();
32
- const pages = screen.getByRole('group', { name: 'Pages' });
33
- expect(within(pages).getAllByRole('option')).toHaveLength(2);
34
- const selections = screen.getByRole('group', { name: 'Selections' });
35
- expect(within(selections).getByRole('option', { name: /Quartz countertop/ })).toBeInTheDocument();
36
- });
37
- it('renders flat results without headings', async () => {
38
- const flat = [{ id: 'a', title: 'Alpha' }];
39
- await openSearch({ results: flat });
40
- expect(screen.getByRole('option', { name: /Alpha/ })).toBeInTheDocument();
41
- expect(screen.queryByText('Pages')).not.toBeInTheDocument();
42
- });
43
- it('moves the active result across groups and points the input at it', async () => {
44
- const { user, input } = await openSearch();
45
- const options = screen.getAllByRole('option');
46
- expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
47
- await user.keyboard('{ArrowDown}{ArrowDown}');
48
- expect(input).toHaveAttribute('aria-activedescendant', options[2].id);
49
- expect(options[2]).toHaveAttribute('aria-selected', 'true');
50
- expect(options[0]).toHaveAttribute('aria-selected', 'false');
51
- });
52
- it('selects the active result on Enter and closes', async () => {
53
- const onSelect = vi.fn();
54
- const { user } = await openSearch({ onSelect });
55
- await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
56
- expect(onSelect).toHaveBeenCalledWith(groups[1].items[0]);
57
- await waitFor(() => expect(screen.queryByRole('combobox')).not.toBeInTheDocument());
58
- });
59
- it('shows result icons only when asked', async () => {
60
- const { unmount } = await openSearch();
61
- expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).toBeNull();
62
- unmount();
63
- await openSearch({ showIcons: true });
64
- expect(screen.getByRole('option', { name: /Dashboard/ }).querySelector('svg')).not.toBeNull();
65
- });
66
- it('switches secondary text and icon to the accent foreground on the active row', async () => {
67
- const { user } = await openSearch({ showIcons: true });
68
- const dashboard = screen.getByRole('option', { name: /Dashboard/ });
69
- const quartz = screen.getByRole('option', { name: /Quartz countertop/ });
70
- // Active row: icon and category follow the accent foreground.
71
- expect(dashboard.querySelector('svg')).toHaveClass('text-accent-foreground/80');
72
- // Inactive row: description and category stay muted.
73
- expect(within(quartz).getByText('Kitchen')).toHaveClass('text-muted-foreground');
74
- expect(within(quartz).getByText('Selection')).toHaveClass('text-muted-foreground');
75
- await user.hover(quartz);
76
- expect(within(quartz).getByText('Kitchen')).toHaveClass('text-accent-foreground/80');
77
- expect(within(quartz).getByText('Selection')).toHaveClass('text-accent-foreground/80');
78
- expect(dashboard.querySelector('svg')).toHaveClass('text-muted-foreground');
79
- });
80
- it('caps the results list at the given height', async () => {
81
- await openSearch({ resultsMaxHeight: '60vh' });
82
- const listbox = screen.getByRole('listbox');
83
- expect(listbox.parentElement).toHaveStyle({ maxHeight: '60vh' });
84
- });
85
- it('has no axe violations with grouped results open', async () => {
86
- await openSearch();
87
- const dialog = screen.getByRole('dialog');
88
- expect(await axe(dialog)).toHaveNoViolations();
89
- });
90
- });
@@ -1,206 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
3
- import userEvent from '@testing-library/user-event';
4
- import Image from '../lib/components/media/Image.svelte';
5
- import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
6
- import MediaGrid from '../lib/components/media/MediaGrid.svelte';
7
- import Lightbox from '../lib/components/media/Lightbox.svelte';
8
- const photos = [
9
- { id: '1', kind: 'image', src: '/one.jpg', alt: 'First photo' },
10
- { id: '2', kind: 'image', src: '/two.jpg', alt: 'Second photo' },
11
- { id: '3', kind: 'image', src: '/three.jpg', alt: 'Third photo' },
12
- ];
13
- describe('Image', () => {
14
- it('renders the image with its alt text and lazy-loads by default', () => {
15
- render(Image, { props: { src: '/photo.jpg', alt: 'A landscape' } });
16
- const img = screen.getByAltText('A landscape');
17
- expect(img).toBeInTheDocument();
18
- expect(img).toHaveAttribute('loading', 'lazy');
19
- expect(img).toHaveAttribute('decoding', 'async');
20
- });
21
- it('honours an explicit loading override', () => {
22
- render(Image, { props: { src: '/hero.jpg', alt: 'Hero', loading: 'eager' } });
23
- expect(screen.getByAltText('Hero')).toHaveAttribute('loading', 'eager');
24
- });
25
- it('swaps in the fallback when the source fails', async () => {
26
- render(Image, { props: { src: '/missing.jpg', alt: 'Missing' } });
27
- const img = screen.getByAltText('Missing');
28
- await fireEvent.error(img);
29
- await waitFor(() => {
30
- expect(screen.queryByAltText('Missing')).not.toBeInTheDocument();
31
- });
32
- // The fallback icon carries the alt text so the failure is still described.
33
- expect(screen.getByText('Failed to load: Missing')).toBeInTheDocument();
34
- });
35
- it('reports load and error through the callbacks', async () => {
36
- const onload = vi.fn();
37
- render(Image, { props: { src: '/photo.jpg', alt: 'Photo', onload } });
38
- await fireEvent.load(screen.getByAltText('Photo'));
39
- expect(onload).toHaveBeenCalledOnce();
40
- });
41
- it('applies sizing classes to the element that carries the size', async () => {
42
- // Regression: `class` used to land on the ratio box's inner, absolutely
43
- // inset element, so `w-24` and friends were silently dropped.
44
- const { container } = render(Image, {
45
- props: { src: '/photo.jpg', alt: 'Photo', ratio: 'square', class: 'w-24' },
46
- });
47
- const sized = container.querySelector('.w-24');
48
- expect(sized).not.toBeNull();
49
- expect(getComputedStyle(sized).position).not.toBe('absolute');
50
- });
51
- it('renders a blurred placeholder behind the image while loading', () => {
52
- const { container } = render(Image, {
53
- props: { src: '/photo.jpg', alt: 'Photo', placeholder: 'data:image/gif;base64,AAAA' },
54
- });
55
- const placeholder = container.querySelector('img[aria-hidden="true"]');
56
- expect(placeholder).toHaveAttribute('src', 'data:image/gif;base64,AAAA');
57
- // Decorative, so it must not be exposed as a second image.
58
- expect(placeholder).toHaveAttribute('alt', '');
59
- });
60
- });
61
- describe('MediaEmbed', () => {
62
- it('shows a facade and requests nothing from the provider before activation', () => {
63
- const { container } = render(MediaEmbed, {
64
- props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' },
65
- });
66
- expect(container.querySelector('iframe')).toBeNull();
67
- expect(screen.getByRole('button', { name: 'Play A song on YouTube' })).toBeInTheDocument();
68
- });
69
- it('injects a privacy-domain iframe once the facade is clicked', async () => {
70
- const user = userEvent.setup();
71
- const { container } = render(MediaEmbed, {
72
- props: { url: 'https://youtu.be/dQw4w9WgXcQ', title: 'A song' },
73
- });
74
- await user.click(screen.getByRole('button', { name: /Play A song/ }));
75
- const iframe = container.querySelector('iframe');
76
- expect(iframe).not.toBeNull();
77
- expect(iframe).toHaveAttribute('title', 'A song');
78
- expect(iframe?.getAttribute('src')).toContain('youtube-nocookie.com');
79
- // Autoplay is only added after the click, satisfying the user-gesture rule.
80
- expect(iframe?.getAttribute('src')).toContain('autoplay=1');
81
- });
82
- it('renders an error state for an unrecognised URL rather than a broken frame', () => {
83
- const { container } = render(MediaEmbed, {
84
- props: { url: 'https://example.com/video', title: 'Unknown' },
85
- });
86
- expect(container.querySelector('iframe')).toBeNull();
87
- expect(screen.getByRole('alert')).toHaveTextContent('This video could not be loaded.');
88
- });
89
- });
90
- describe('MediaGrid', () => {
91
- it('renders one labelled tile per item', () => {
92
- render(MediaGrid, { props: { items: photos, label: 'Project photos' } });
93
- expect(screen.getByRole('list', { name: 'Project photos' })).toBeInTheDocument();
94
- expect(screen.getByRole('button', { name: 'First photo' })).toBeInTheDocument();
95
- expect(screen.getByRole('button', { name: 'Third photo' })).toBeInTheDocument();
96
- });
97
- it('keeps a single tab stop by default', () => {
98
- render(MediaGrid, { props: { items: photos } });
99
- const tiles = screen.getAllByRole('button');
100
- expect(tiles[0]).toHaveAttribute('tabindex', '0');
101
- expect(tiles[1]).toHaveAttribute('tabindex', '-1');
102
- });
103
- it('makes every tile tabbable when singleTabStop is off', () => {
104
- render(MediaGrid, { props: { items: photos, singleTabStop: false } });
105
- for (const tile of screen.getAllByRole('button')) {
106
- expect(tile).toHaveAttribute('tabindex', '0');
107
- }
108
- });
109
- it('moves focus with the arrow keys', async () => {
110
- render(MediaGrid, { props: { items: photos, lightbox: false } });
111
- const tiles = screen.getAllByRole('button');
112
- tiles[0].focus();
113
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'ArrowRight' });
114
- expect(document.activeElement).toBe(tiles[1]);
115
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'End' });
116
- expect(document.activeElement).toBe(tiles[2]);
117
- await fireEvent.keyDown(screen.getByRole('list'), { key: 'Home' });
118
- expect(document.activeElement).toBe(tiles[0]);
119
- });
120
- it('reports the selected index without opening a lightbox when disabled', async () => {
121
- const user = userEvent.setup();
122
- const onSelect = vi.fn();
123
- render(MediaGrid, { props: { items: photos, lightbox: false, onSelect } });
124
- await user.click(screen.getByRole('button', { name: 'Second photo' }));
125
- expect(onSelect).toHaveBeenCalledWith(1, photos[1]);
126
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
127
- });
128
- it('renders an empty state rather than a bare list', () => {
129
- render(MediaGrid, { props: { items: [] } });
130
- expect(screen.getByText('No media to show.')).toBeInTheDocument();
131
- expect(screen.queryByRole('list')).not.toBeInTheDocument();
132
- });
133
- });
134
- describe('Lightbox', () => {
135
- it('renders nothing until it is opened', () => {
136
- render(Lightbox, { props: { items: photos } });
137
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
138
- });
139
- it('exposes an accessible name taken from the active item', async () => {
140
- render(Lightbox, { props: { items: photos, open: true } });
141
- const dialog = await screen.findByRole('dialog');
142
- expect(dialog).toHaveAccessibleName('First photo');
143
- });
144
- it('navigates between items with the arrow keys', async () => {
145
- render(Lightbox, { props: { items: photos, open: true } });
146
- const dialog = await screen.findByRole('dialog');
147
- expect(dialog).toHaveAccessibleName('First photo');
148
- await fireEvent.keyDown(dialog, { key: 'ArrowRight' });
149
- await waitFor(() => expect(dialog).toHaveAccessibleName('Second photo'));
150
- await fireEvent.keyDown(dialog, { key: 'End' });
151
- await waitFor(() => expect(dialog).toHaveAccessibleName('Third photo'));
152
- // Wraps by default, so Next from the last item returns to the first.
153
- await fireEvent.keyDown(dialog, { key: 'ArrowRight' });
154
- await waitFor(() => expect(dialog).toHaveAccessibleName('First photo'));
155
- });
156
- it('stops at the ends when looping is disabled', async () => {
157
- render(Lightbox, { props: { items: photos, open: true, loop: false } });
158
- const dialog = await screen.findByRole('dialog');
159
- await fireEvent.keyDown(dialog, { key: 'ArrowLeft' });
160
- await waitFor(() => expect(dialog).toHaveAccessibleName('First photo'));
161
- });
162
- it('offers labelled navigation and close controls', async () => {
163
- render(Lightbox, { props: { items: photos, open: true } });
164
- await screen.findByRole('dialog');
165
- expect(screen.getByRole('button', { name: 'Previous item' })).toBeInTheDocument();
166
- expect(screen.getByRole('button', { name: 'Next item' })).toBeInTheDocument();
167
- expect(screen.getByRole('button', { name: 'Close viewer' })).toBeInTheDocument();
168
- });
169
- it('hides the navigation controls for a single item', async () => {
170
- render(Lightbox, { props: { items: [photos[0]], open: true } });
171
- await screen.findByRole('dialog');
172
- expect(screen.queryByRole('button', { name: 'Next item' })).not.toBeInTheDocument();
173
- });
174
- it('renders an empty state instead of a blank stage', async () => {
175
- render(Lightbox, { props: { items: [], open: true } });
176
- await screen.findByRole('dialog');
177
- expect(screen.getByText('No media to show.')).toBeInTheDocument();
178
- });
179
- it('keeps a zoom applied instead of snapping it straight back', async () => {
180
- // Regression: the effect that resets zoom on navigation used to read the
181
- // zoom state while tracking, so it subscribed to the scale it had just
182
- // written and undid every zoom immediately.
183
- const user = userEvent.setup();
184
- render(Lightbox, { props: { items: photos, open: true } });
185
- const dialog = await screen.findByRole('dialog');
186
- const image = dialog.querySelector('img');
187
- expect(image.style.transform).toContain('scale(1)');
188
- await user.click(screen.getByRole('button', { name: 'Zoom in' }));
189
- await waitFor(() => expect(image.style.transform).toContain('scale(1.5)'));
190
- // Zoom out is only enabled once zoomed, so its state proves the same thing.
191
- expect(screen.getByRole('button', { name: 'Zoom out' })).toBeEnabled();
192
- });
193
- it('resets zoom when the item changes', async () => {
194
- const user = userEvent.setup();
195
- render(Lightbox, { props: { items: photos, open: true } });
196
- const dialog = await screen.findByRole('dialog');
197
- await user.click(screen.getByRole('button', { name: 'Zoom in' }));
198
- await waitFor(() => expect(dialog.querySelector('img').style.transform).toContain('scale(1.5)'));
199
- await user.click(screen.getByRole('button', { name: 'Next item' }));
200
- await waitFor(() => {
201
- const next = dialog.querySelector('img');
202
- expect(next.style.transform).toContain('scale(1)');
203
- expect(next.style.transform).not.toContain('scale(1.5)');
204
- });
205
- });
206
- });
@@ -1,273 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { render, screen, waitFor } from '@testing-library/svelte';
3
- import Video from '../lib/components/media/Video.svelte';
4
- import MediaEmbed from '../lib/components/media/MediaEmbed.svelte';
5
- import { canPlayHlsNatively, hasHlsLoader, resetHlsLoader, setHlsLoader, } from '../lib/utils/hls.js';
6
- import { cloudflareStream } from '../lib/utils/media.js';
7
- const STREAM_ACCOUNT = { customerCode: 'abc123' };
8
- const HLS_URL = 'https://customer-abc123.cloudflarestream.com/a1b2c3/manifest/video.m3u8';
9
- /** Minimal stand-in for hls.js, enough to observe how the player drives it. */
10
- function createFakeHls() {
11
- const instances = [];
12
- class FakeHls {
13
- constructor(config) {
14
- this.config = config;
15
- this.handlers = new Map();
16
- this.loadSource = vi.fn();
17
- this.attachMedia = vi.fn();
18
- this.destroy = vi.fn();
19
- this.startLoad = vi.fn();
20
- this.recoverMediaError = vi.fn();
21
- this.on = vi.fn((event, callback) => {
22
- this.handlers.set(event, callback);
23
- });
24
- instances.push(this);
25
- }
26
- emit(event, data) {
27
- this.handlers.get(event)?.(event, data);
28
- }
29
- }
30
- FakeHls.isSupported = vi.fn(() => true);
31
- FakeHls.Events = { ERROR: 'hlsError' };
32
- FakeHls.ErrorTypes = { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' };
33
- return { FakeHls, instances };
34
- }
35
- /** jsdom reports no HLS support, matching Chrome and Firefox. */
36
- function stubNativeHls(supported) {
37
- vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockImplementation((type) => supported && type.toLowerCase().includes('mpegurl') ? 'maybe' : '');
38
- }
39
- beforeEach(() => {
40
- resetHlsLoader();
41
- vi.spyOn(console, 'warn').mockImplementation(() => { });
42
- });
43
- afterEach(() => {
44
- resetHlsLoader();
45
- vi.restoreAllMocks();
46
- });
47
- describe('canPlayHlsNatively', () => {
48
- it('is false when the browser rejects the HLS MIME types', () => {
49
- stubNativeHls(false);
50
- expect(canPlayHlsNatively()).toBe(false);
51
- });
52
- it('is true when the browser reports playback support, as Safari does', () => {
53
- stubNativeHls(true);
54
- expect(canPlayHlsNatively()).toBe(true);
55
- });
56
- });
57
- describe('setHlsLoader', () => {
58
- it('reports whether a loader is available without loading it', () => {
59
- expect(hasHlsLoader()).toBe(false);
60
- setHlsLoader(() => ({}));
61
- expect(hasHlsLoader()).toBe(true);
62
- });
63
- it('detects a global Hls, as set by the CDN build', () => {
64
- const { FakeHls } = createFakeHls();
65
- globalThis.Hls = FakeHls;
66
- try {
67
- expect(hasHlsLoader()).toBe(true);
68
- }
69
- finally {
70
- delete globalThis.Hls;
71
- }
72
- });
73
- });
74
- describe('Video with an HLS source', () => {
75
- it('plays natively without hls.js where the browser supports it', async () => {
76
- stubNativeHls(true);
77
- const { FakeHls, instances } = createFakeHls();
78
- setHlsLoader(() => FakeHls);
79
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
80
- const video = container.querySelector('video');
81
- await waitFor(() => expect(video.getAttribute('src')).toBe(HLS_URL));
82
- // Safari needs no library, so none should be constructed.
83
- expect(instances).toHaveLength(0);
84
- });
85
- it('attaches hls.js when the browser cannot play HLS natively', async () => {
86
- stubNativeHls(false);
87
- const { FakeHls, instances } = createFakeHls();
88
- setHlsLoader(() => FakeHls);
89
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
90
- await waitFor(() => expect(instances).toHaveLength(1));
91
- expect(instances[0].loadSource).toHaveBeenCalledWith(HLS_URL);
92
- expect(instances[0].attachMedia).toHaveBeenCalledWith(container.querySelector('video'));
93
- });
94
- it('omits the HLS <source> when hls.js drives playback', async () => {
95
- // Leaving a playlist the browser cannot decode would make the element
96
- // report an error before hls.js ever attaches.
97
- stubNativeHls(false);
98
- const { FakeHls } = createFakeHls();
99
- setHlsLoader(() => FakeHls);
100
- const { container } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
101
- await waitFor(() => expect(container.querySelectorAll('source')).toHaveLength(0));
102
- });
103
- it('keeps progressive fallbacks as <source> alongside a stream', async () => {
104
- stubNativeHls(false);
105
- const { FakeHls } = createFakeHls();
106
- setHlsLoader(() => FakeHls);
107
- const { container } = render(Video, {
108
- props: {
109
- src: [
110
- { src: HLS_URL, type: 'application/vnd.apple.mpegurl' },
111
- { src: '/fallback.mp4', type: 'video/mp4' },
112
- ],
113
- label: 'Stream',
114
- },
115
- });
116
- await waitFor(() => {
117
- const sources = container.querySelectorAll('source');
118
- expect(sources).toHaveLength(1);
119
- expect(sources[0]).toHaveAttribute('src', '/fallback.mp4');
120
- });
121
- });
122
- it('passes hls.js config through', async () => {
123
- stubNativeHls(false);
124
- const { FakeHls, instances } = createFakeHls();
125
- render(Video, {
126
- props: {
127
- src: HLS_URL,
128
- label: 'Stream',
129
- hlsLoader: () => FakeHls,
130
- hlsConfig: { lowLatencyMode: true },
131
- },
132
- });
133
- await waitFor(() => expect(instances).toHaveLength(1));
134
- expect(instances[0].config).toMatchObject({ lowLatencyMode: true });
135
- });
136
- it('starts downloading immediately by default', async () => {
137
- stubNativeHls(false);
138
- const { FakeHls, instances } = createFakeHls();
139
- setHlsLoader(() => FakeHls);
140
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
141
- await waitFor(() => expect(instances).toHaveLength(1));
142
- expect(instances[0].config).toMatchObject({ autoStartLoad: true });
143
- });
144
- it('holds off downloading until play when preload is none', async () => {
145
- // hls.js fetches through its own loader and ignores the element's
146
- // `preload`, so a stream would otherwise pull segments the moment it
147
- // mounted — starving any other player on the page of bandwidth.
148
- stubNativeHls(false);
149
- const { FakeHls, instances } = createFakeHls();
150
- setHlsLoader(() => FakeHls);
151
- render(Video, { props: { src: HLS_URL, label: 'Stream', preload: 'none' } });
152
- await waitFor(() => expect(instances).toHaveLength(1));
153
- expect(instances[0].config).toMatchObject({ autoStartLoad: false });
154
- expect(instances[0].startLoad).not.toHaveBeenCalled();
155
- await screen.getByRole('button', { name: 'Play video' }).click();
156
- await waitFor(() => expect(instances[0].startLoad).toHaveBeenCalled());
157
- });
158
- it('lets an explicit hlsConfig override the preload-derived default', async () => {
159
- stubNativeHls(false);
160
- const { FakeHls, instances } = createFakeHls();
161
- setHlsLoader(() => FakeHls);
162
- render(Video, {
163
- props: {
164
- src: HLS_URL,
165
- label: 'Stream',
166
- preload: 'none',
167
- hlsConfig: { autoStartLoad: true },
168
- },
169
- });
170
- await waitFor(() => expect(instances).toHaveLength(1));
171
- expect(instances[0].config).toMatchObject({ autoStartLoad: true });
172
- });
173
- it('recovers from non-fatal and recoverable hls.js errors instead of giving up', async () => {
174
- stubNativeHls(false);
175
- const { FakeHls, instances } = createFakeHls();
176
- setHlsLoader(() => FakeHls);
177
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
178
- await waitFor(() => expect(instances).toHaveLength(1));
179
- instances[0].emit('hlsError', { fatal: false, type: 'networkError' });
180
- expect(instances[0].startLoad).not.toHaveBeenCalled();
181
- instances[0].emit('hlsError', { fatal: true, type: 'networkError' });
182
- expect(instances[0].startLoad).toHaveBeenCalled();
183
- instances[0].emit('hlsError', { fatal: true, type: 'mediaError' });
184
- expect(instances[0].recoverMediaError).toHaveBeenCalled();
185
- });
186
- it('explains itself when hls.js is needed but unavailable', async () => {
187
- stubNativeHls(false);
188
- // No loader registered, and no global Hls.
189
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
190
- const alert = await screen.findByRole('alert');
191
- expect(alert).toHaveTextContent('This browser cannot play adaptive streams.');
192
- expect(alert).toHaveTextContent(/hls\.js/);
193
- });
194
- it('does not warn about missing captions for a stream, which carries its own', async () => {
195
- stubNativeHls(true);
196
- render(Video, { props: { src: HLS_URL, label: 'Stream' } });
197
- await waitFor(() => {
198
- expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining('No caption tracks'));
199
- });
200
- });
201
- it('tears hls.js down when the player unmounts', async () => {
202
- stubNativeHls(false);
203
- const { FakeHls, instances } = createFakeHls();
204
- setHlsLoader(() => FakeHls);
205
- const { unmount } = render(Video, { props: { src: HLS_URL, label: 'Stream' } });
206
- await waitFor(() => expect(instances).toHaveLength(1));
207
- unmount();
208
- expect(instances[0].destroy).toHaveBeenCalled();
209
- });
210
- });
211
- describe('Video with a Cloudflare Stream item', () => {
212
- it('takes source, poster, and label from the item', async () => {
213
- stubNativeHls(true);
214
- const item = cloudflareStream('a1b2c3', { ...STREAM_ACCOUNT, title: 'Product tour' });
215
- const { container } = render(Video, { props: { item } });
216
- const video = container.querySelector('video');
217
- expect(screen.getByRole('region', { name: 'Product tour' })).toBeInTheDocument();
218
- expect(video).toHaveAttribute('poster', item.poster);
219
- await waitFor(() => expect(video.getAttribute('src')).toBe(HLS_URL));
220
- });
221
- it('lets explicit props override the item', () => {
222
- stubNativeHls(true);
223
- const item = cloudflareStream('a1b2c3', { ...STREAM_ACCOUNT, title: 'Product tour' });
224
- const { container } = render(Video, {
225
- props: { item, label: 'Custom label', poster: '/own-poster.jpg' },
226
- });
227
- expect(screen.getByRole('region', { name: 'Custom label' })).toBeInTheDocument();
228
- expect(container.querySelector('video')).toHaveAttribute('poster', '/own-poster.jpg');
229
- });
230
- });
231
- describe('MediaEmbed with Cloudflare Stream', () => {
232
- it('shows a facade with a derived Stream thumbnail', () => {
233
- const { container } = render(MediaEmbed, {
234
- props: { provider: 'cloudflare', videoId: 'a1b2c3', customerCode: 'abc', title: 'Tour' },
235
- });
236
- expect(container.querySelector('iframe')).toBeNull();
237
- const facade = screen.getByRole('button', { name: 'Play Tour on Cloudflare Stream' });
238
- expect(facade).toBeInTheDocument();
239
- expect(facade.querySelector('img')).toHaveAttribute('src', expect.stringContaining('customer-abc.cloudflarestream.com/a1b2c3/thumbnails/thumbnail.jpg'));
240
- });
241
- it('injects the Stream player iframe once activated', async () => {
242
- const { container } = render(MediaEmbed, {
243
- props: {
244
- provider: 'cloudflare',
245
- videoId: 'a1b2c3',
246
- customerCode: 'abc',
247
- title: 'Tour',
248
- eager: true,
249
- },
250
- });
251
- const iframe = container.querySelector('iframe');
252
- expect(iframe).toHaveAttribute('src', 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe');
253
- expect(iframe).toHaveAttribute('title', 'Tour');
254
- });
255
- it('resolves the account from a full Stream URL', () => {
256
- const { container } = render(MediaEmbed, {
257
- props: {
258
- url: 'https://customer-abc.cloudflarestream.com/a1b2c3/watch',
259
- title: 'Tour',
260
- eager: true,
261
- },
262
- });
263
- expect(container.querySelector('iframe')).toHaveAttribute('src', 'https://customer-abc.cloudflarestream.com/a1b2c3/iframe');
264
- });
265
- it('renders an error state, not a broken frame, when the account is missing', () => {
266
- const { container } = render(MediaEmbed, {
267
- props: { provider: 'cloudflare', videoId: 'a1b2c3', title: 'Tour' },
268
- });
269
- expect(container.querySelector('iframe')).toBeNull();
270
- expect(screen.getByRole('alert')).toHaveTextContent('This video could not be loaded.');
271
- expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('customerCode'));
272
- });
273
- });