1// @vitest-environment jsdom
2
3import { act, StrictMode, useState } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import { MediaPlaybackProvider, MediaPlaybackSlot } from "./MediaPlaybackHost";
8
9let root: Root;
10let container: HTMLDivElement;
11let playing: WeakSet<HTMLMediaElement>;
12let pause: ReturnType<typeof vi.spyOn>;
13let load: ReturnType<typeof vi.spyOn>;
14let nextPlayer = 0;
15
16function Player({ label }: { readonly label: string }) {
17 const [instance] = useState(() => nextPlayer++);
18 return (
19 <span data-media-player>
20 <video src="/video.mp4" aria-label={label} controls data-instance={instance} />
21 <button type="button">Playback control</button>
22 </span>
23 );
24}
25
26beforeEach(() => {
27 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
28 playing = new WeakSet();
29 nextPlayer = 0;
30 vi.spyOn(HTMLMediaElement.prototype, "paused", "get").mockImplementation(function (
31 this: HTMLMediaElement,
32 ) {
33 return !playing.has(this);
34 });
35 vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (
36 this: HTMLMediaElement,
37 ) {
38 playing.add(this);
39 this.dispatchEvent(new Event("play"));
40 return Promise.resolve();
41 });
42 pause = vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(function (
43 this: HTMLMediaElement,
44 ) {
45 playing.delete(this);
46 this.dispatchEvent(new Event("pause"));
47 });
48 load = vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => undefined);
49 container = document.createElement("div");
50 document.body.append(container);
51 root = createRoot(container);
52});
53
54afterEach(async () => {
55 await act(() => root.unmount());
56 container.remove();
57 Reflect.deleteProperty(document, "pictureInPictureElement");
58 Reflect.deleteProperty(document, "exitPictureInPicture");
59 Reflect.deleteProperty(document, "fullscreenElement");
60 vi.restoreAllMocks();
61 vi.unstubAllGlobals();
62});
63
64async function renderSlots(
65 slots: ReadonlyArray<{ key: string; identity?: string; label?: string }>,
66) {
67 await act(() => {
68 root.render(
69 <StrictMode>
70 <MediaPlaybackProvider>
71 <main>
72 {slots.map(({ key, identity = key, label = key }) => (
73 <div key={key} data-chat={key}>
74 <MediaPlaybackSlot identity={identity}>
75 <Player label={label} />
76 </MediaPlaybackSlot>
77 </div>
78 ))}
79 </main>
80 </MediaPlaybackProvider>
81 </StrictMode>,
82 );
83 });
84}
85
86function videoIn(selector: string) {
87 const video = container.querySelector<HTMLVideoElement>(`${selector} video`);
88 if (!video) throw new Error(`Missing video in ${selector}`);
89 return video;
90}
91
92describe("persistent media playback", () => {
93 it("keeps the same playing video, time, and component state across chats and return", async () => {
94 await renderSlots([{ key: "source", label: "Demo" }]);
95 const video = videoIn('[data-chat="source"]');
96 const instance = video.dataset.instance;
97 await act(() => video.play());
98 video.currentTime = 42.5;
99
100 await renderSlots([{ key: "other" }]);
101 expect(videoIn("[data-media-floating-player]")).toBe(video);
102 expect(video.currentTime).toBe(42.5);
103 expect(video.paused).toBe(false);
104 expect(container.querySelector('[aria-label="Picture in picture: Demo"]')).not.toBeNull();
105
106 await renderSlots([{ key: "source", label: "Updated demo" }]);
107 expect(videoIn('[data-chat="source"]')).toBe(video);
108 expect(video.currentTime).toBe(42.5);
109 expect(video.dataset.instance).toBe(instance);
110 expect(video.getAttribute("aria-label")).toBe("Updated demo");
111 expect(container.querySelector("[data-media-floating-player]")?.hasAttribute("hidden")).toBe(
112 true,
113 );
114 expect(pause.mock.contexts).not.toContain(video);
115 expect(load.mock.contexts).not.toContain(video);
116 });
117
118 it("releases a paused video when its inline slot disappears", async () => {
119 await renderSlots([{ key: "source" }]);
120 const original = videoIn('[data-chat="source"]');
121 await renderSlots([]);
122
123 expect(original.isConnected).toBe(false);
124 expect(original.hasAttribute("src")).toBe(false);
125 expect(load).toHaveBeenCalledOnce();
126 expect(container.querySelector("video")).toBeNull();
127
128 await renderSlots([{ key: "source" }]);
129 expect(videoIn('[data-chat="source"]')).not.toBe(original);
130 });
131
132 it("stops playback and releases the source through the keyboard-accessible close action", async () => {
133 await renderSlots([{ key: "source" }]);
134 const video = videoIn('[data-chat="source"]');
135 await act(() => video.play());
136 await renderSlots([]);
137
138 const close = container.querySelector<HTMLButtonElement>(
139 '[aria-label="Close picture in picture"]',
140 );
141 expect(close?.tagName).toBe("BUTTON");
142 close?.focus();
143 expect(document.activeElement).toBe(close);
144 await act(() => close?.click());
145
146 expect(video.paused).toBe(true);
147 expect(video.hasAttribute("src")).toBe(false);
148 expect(container.querySelector("video")).toBeNull();
149 });
150
151 it("retains only the latest detached player and releases the one it replaces", async () => {
152 await renderSlots([{ key: "first" }]);
153 const first = videoIn('[data-chat="first"]');
154 await act(() => first.play());
155 await renderSlots([{ key: "second" }]);
156 const second = videoIn('[data-chat="second"]');
157 await act(() => second.play());
158 await renderSlots([]);
159
160 expect(videoIn("[data-media-floating-player]")).toBe(second);
161 expect(first.isConnected).toBe(false);
162 expect(first.paused).toBe(true);
163 expect(first.hasAttribute("src")).toBe(false);
164 expect(container.querySelectorAll("video")).toHaveLength(1);
165 });
166
167 it("retains the most recently played video regardless of the DOM detach order", async () => {
168 await renderSlots([{ key: "first" }, { key: "second" }]);
169 const first = videoIn('[data-chat="first"]');
170 const second = videoIn('[data-chat="second"]');
171 await act(() => second.play());
172 await act(() => first.play());
173 await renderSlots([]);
174
175 expect(videoIn("[data-media-floating-player]")).toBe(first);
176 expect(first.paused).toBe(false);
177 expect(second.paused).toBe(true);
178 expect(second.hasAttribute("src")).toBe(false);
179 expect(second.isConnected).toBe(false);
180 expect(container.querySelectorAll("video")).toHaveLength(1);
181 });
182
183 it("keeps the prior floating video until the more recently played inline video detaches", async () => {
184 await renderSlots([{ key: "first" }, { key: "second" }]);
185 const first = videoIn('[data-chat="first"]');
186 const second = videoIn('[data-chat="second"]');
187 await act(() => second.play());
188 await act(() => first.play());
189 expect(second.paused).toBe(false);
190
191 await renderSlots([{ key: "first" }]);
192 expect(videoIn('[data-chat="first"]')).toBe(first);
193 expect(first.paused).toBe(false);
194 expect(videoIn("[data-media-floating-player]")).toBe(second);
195 expect(second.paused).toBe(false);
196
197 await renderSlots([]);
198 expect(videoIn("[data-media-floating-player]")).toBe(first);
199 expect(second.paused).toBe(true);
200 expect(second.hasAttribute("src")).toBe(false);
201 });
202
203 it("releases a floating player when playback ends", async () => {
204 await renderSlots([{ key: "source" }]);
205 const video = videoIn('[data-chat="source"]');
206 await act(() => video.play());
207 await renderSlots([]);
208 await act(() => video.dispatchEvent(new Event("ended")));
209
210 expect(video.isConnected).toBe(false);
211 expect(video.paused).toBe(true);
212 expect(video.hasAttribute("src")).toBe(false);
213 });
214
215 it("closes the floating player with Escape from its own Close button", async () => {
216 await renderSlots([{ key: "source" }]);
217 const video = videoIn('[data-chat="source"]');
218 await act(() => video.play());
219 await renderSlots([]);
220 const close = container.querySelector<HTMLButtonElement>(
221 '[aria-label="Close picture in picture"]',
222 );
223 await act(() =>
224 close?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
225 );
226 expect(video.paused).toBe(true);
227 expect(video.hasAttribute("src")).toBe(false);
228 expect(video.isConnected).toBe(false);
229 });
230
231 it("closes with Escape from a focused portaled player control", async () => {
232 await renderSlots([{ key: "source" }]);
233 const video = videoIn('[data-chat="source"]');
234 await act(() => video.play());
235 await renderSlots([]);
236 const control = container.querySelector<HTMLButtonElement>(
237 "[data-media-floating-player] [data-media-player] button",
238 );
239 if (!control) throw new Error("Missing portaled playback control");
240 control.focus();
241 expect(document.activeElement).toBe(control);
242
243 await act(() =>
244 control.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
245 );
246
247 expect(video.paused).toBe(true);
248 expect(video.hasAttribute("src")).toBe(false);
249 expect(video.isConnected).toBe(false);
250 expect(pause.mock.contexts.filter((context: unknown) => context === video)).toHaveLength(1);
251 expect(load.mock.contexts.filter((context: unknown) => context === video)).toHaveLength(1);
252 });
253
254 it("captures inline videos before provider teardown removes the portal children", async () => {
255 await renderSlots([{ key: "source" }]);
256 const video = videoIn('[data-chat="source"]');
257 await act(() => video.play());
258 await act(() => root.render(null));
259 expect(video.paused).toBe(true);
260 expect(video.hasAttribute("src")).toBe(false);
261 expect(load.mock.contexts).toContain(video);
262 });
263
264 it("leaves Escape to the browser while the floating video is fullscreen", async () => {
265 await renderSlots([{ key: "source" }]);
266 const video = videoIn('[data-chat="source"]');
267 await act(() => video.play());
268 await renderSlots([]);
269 Object.defineProperty(document, "fullscreenElement", {
270 configurable: true,
271 value: video.parentElement,
272 });
273 const control = container.querySelector<HTMLButtonElement>(
274 "[data-media-floating-player] [data-media-player] button",
275 )!;
276 const escape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
277 await act(() => control.dispatchEvent(escape));
278 expect(escape.defaultPrevented).toBe(false);
279 expect(video.paused).toBe(false);
280 expect(video.isConnected).toBe(true);
281 });
282
283 it("does not let another occurrence of the same source steal an attached video", async () => {
284 await renderSlots([
285 { key: "first", identity: "shared" },
286 { key: "second", identity: "shared" },
287 ]);
288 const first = videoIn('[data-chat="first"]');
289 const second = videoIn('[data-chat="second"]');
290 expect(first).not.toBe(second);
291 await act(() => first.play());
292 first.currentTime = 12;
293 await renderSlots([{ key: "second", identity: "shared", label: "Other occurrence" }]);
294
295 expect(videoIn('[data-chat="second"]')).toBe(second);
296 expect(videoIn("[data-media-floating-player]")).toBe(first);
297
298 await renderSlots([
299 { key: "first", identity: "shared" },
300 { key: "second", identity: "shared" },
301 ]);
302 expect(videoIn('[data-chat="first"]')).toBe(first);
303 expect(first.currentTime).toBe(12);
304 expect(videoIn('[data-chat="second"]')).toBe(second);
305 });
306
307 it("retains a paused system picture-in-picture video and releases it on provider teardown", async () => {
308 await renderSlots([{ key: "source" }]);
309 const video = videoIn('[data-chat="source"]');
310 Object.defineProperty(document, "pictureInPictureElement", {
311 configurable: true,
312 value: video,
313 });
314 const exit = vi.fn(() => Promise.resolve());
315 Object.defineProperty(document, "exitPictureInPicture", { configurable: true, value: exit });
316 await renderSlots([]);
317 expect(videoIn("[data-media-floating-player]")).toBe(video);
318
319 await act(() => root.render(null));
320 expect(video.paused).toBe(true);
321 expect(video.hasAttribute("src")).toBe(false);
322 expect(exit).toHaveBeenCalledOnce();
323 });
324
325 it("supports isolated players without an app provider", async () => {
326 await act(() =>
327 root.render(
328 <MediaPlaybackSlot identity="test">
329 <Player label="Standalone" />
330 </MediaPlaybackSlot>,
331 ),
332 );
333 expect(videoIn(":scope").getAttribute("aria-label")).toBe("Standalone");
334 expect(container.querySelector("[data-media-floating-player]")).toBeNull();
335 });
336});