1// @vitest-environment jsdom
2import { act } from "react";
3import { createRoot, type Root } from "react-dom/client";
4import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
5
6vi.mock("./MediaActions", () => ({ useMediaActionMenu: () => ({ showMenu: vi.fn() }) }));
7
8import { MediaVideoControls } from "./MediaVideoControls";
9
10let root: Root;
11let container: HTMLDivElement;
12let video: HTMLVideoElement;
13
14beforeEach(async () => {
15 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
16 container = document.createElement("div");
17 video = document.createElement("video");
18 document.body.append(video, container);
19 let paused = true;
20 Object.defineProperties(video, {
21 paused: { get: () => paused },
22 duration: { value: 125, configurable: true },
23 play: {
24 value: vi.fn(async () => {
25 paused = false;
26 video.dispatchEvent(new Event("play"));
27 }),
28 },
29 pause: {
30 value: vi.fn(() => {
31 paused = true;
32 video.dispatchEvent(new Event("pause"));
33 }),
34 },
35 });
36 root = createRoot(container);
37 await act(() => root.render(<MediaVideoControls video={video} actionsSource={undefined} />));
38});
39
40afterEach(async () => {
41 await act(() => root.unmount());
42 container.remove();
43 video.remove();
44 Reflect.deleteProperty(document, "fullscreenElement");
45 Reflect.deleteProperty(document, "exitFullscreen");
46 vi.unstubAllGlobals();
47});
48
49function button(name: string) {
50 const node = container.querySelector<HTMLButtonElement>(`button[aria-label="${name}"]`);
51 expect(node, name).not.toBeNull();
52 return node!;
53}
54
55async function change(label: string, value: string) {
56 const input = container.querySelector<HTMLInputElement | HTMLSelectElement>(
57 `[aria-label="${label}"]`,
58 )!;
59 const prototype =
60 input instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLSelectElement.prototype;
61 await act(() => {
62 Object.getOwnPropertyDescriptor(prototype, "value")!.set!.call(input, value);
63 input.dispatchEvent(
64 new Event(input instanceof HTMLInputElement ? "input" : "change", { bubbles: true }),
65 );
66 });
67}
68
69it("plays and pauses the actual media element and follows external playback changes", async () => {
70 await act(async () => button("Play video").click());
71 expect(video.play).toHaveBeenCalledOnce();
72 expect(video.paused).toBe(false);
73 await act(() => button("Pause video").click());
74 expect(video.pause).toHaveBeenCalledOnce();
75 expect(button("Play video")).toBeDefined();
76 await act(async () => video.play());
77 expect(button("Pause video")).toBeDefined();
78});
79
80it("seeks without replacing the source and publishes the current time accessibly", async () => {
81 video.src = "https://media.test/clip.mp4";
82 await change("Seek video", "42.5");
83 expect(video.currentTime).toBe(42.5);
84 expect(video.src).toBe("https://media.test/clip.mp4");
85 await act(() => video.dispatchEvent(new Event("timeupdate")));
86 expect(container.querySelector('[aria-label="Seek video"]')?.getAttribute("aria-valuetext")).toBe(
87 "0:42 of 2:05",
88 );
89});
90
91it("exposes volume and speed at every player width, and applies both to the video", async () => {
92 expect(button("Video settings").getAttribute("aria-expanded")).toBe("false");
93 await act(() => button("Video settings").click());
94 expect(button("Video settings").getAttribute("aria-expanded")).toBe("true");
95 await change("Video volume", "0.35");
96 expect(video.volume).toBe(0.35);
97 expect(video.muted).toBe(false);
98 await change("Playback speed", "1.5");
99 expect(video.playbackRate).toBe(1.5);
100 await act(() => button("Mute video").click());
101 expect(video.muted).toBe(true);
102 await act(() => video.dispatchEvent(new Event("volumechange")));
103 await act(() => button("Unmute video").click());
104 expect(video.muted).toBe(false);
105});
106
107it("keeps Play available after a rejected play request", async () => {
108 vi.mocked(video.play).mockRejectedValueOnce(new Error("gesture denied"));
109 await act(async () => button("Play video").click());
110 expect(container.querySelector('[role="alert"]')?.textContent).toContain("Try again");
111 await act(async () => button("Play video").click());
112 expect(container.querySelector('[role="alert"]')).toBeNull();
113 expect(button("Pause video")).toBeDefined();
114});
115
116it("disables seeking until the video has a finite duration", async () => {
117 Object.defineProperty(video, "duration", { value: Infinity });
118 await act(() => video.dispatchEvent(new Event("durationchange")));
119 expect(container.querySelector<HTMLInputElement>('[aria-label="Seek video"]')?.disabled).toBe(
120 true,
121 );
122});
123
124it("exits fullscreen with Escape from a focused playback control without stopping the video", async () => {
125 Object.defineProperty(document, "fullscreenElement", { configurable: true, value: video });
126 const exit = vi.fn(async () => {
127 Reflect.deleteProperty(document, "fullscreenElement");
128 document.dispatchEvent(new Event("fullscreenchange"));
129 });
130 Object.defineProperty(document, "exitFullscreen", { configurable: true, value: exit });
131 await act(async () => button("Play video").click());
132 const control = button("Pause video");
133 control.focus();
134 await act(async () =>
135 control.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })),
136 );
137 expect(exit).toHaveBeenCalledOnce();
138 expect(video.paused).toBe(false);
139});