Skip to content
Source
Sign inSign up
Open History to see the last commit

useMediaVideoPlayback.test.tsx

364 lines13.0 KB

1import { RegistryContext } from "@effect/atom-react";
2import { AtomRegistry } from "effect/reactivity";
3import { act, type ReactNode } from "react";
4import { createRoot } from "react-dom/client";
5import type { AppStateStatus } from "react-native";
6import { expect, it, vi } from "vite-plus/test";
7import { EnvironmentId } from "@t3tools/contracts";
8​
9const mocks = vi.hoisted(() => ({
10 focused: true,
11 appState: "active" as AppStateStatus,
12 appStateListeners: new Set<(state: AppStateStatus) => void>(),
13 players: [] as ReturnType<typeof createPlayer>[],
14 refresh: vi.fn<() => Promise<string | null>>(),
15 replace: vi.fn<() => Promise<void>>(),
16 thumbnail: vi.fn<(key: string, source: unknown, signal: AbortSignal) => Promise<null>>(),
17}));
18vi.mock("@react-navigation/native", () => ({ useIsFocused: () => mocks.focused }));
19vi.mock("react-native", () => ({
20 Platform: { OS: "ios" },
21 StyleSheet: { absoluteFill: {} },
22 View: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
23 Pressable: ({
24 children,
25 onPress,
26 accessibilityLabel,
27 }: {
28 children?: ReactNode;
29 onPress?: () => void;
30 accessibilityLabel?: string;
31 }) => (
32 <button aria-label={accessibilityLabel} onClick={onPress}>
33 {children}
34 </button>
35 ),
36 AppState: {
37 get currentState() {
38 return mocks.appState;
39 },
40 addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => {
41 mocks.appStateListeners.add(listener);
42 return { remove: () => mocks.appStateListeners.delete(listener) };
43 },
44 },
45}));
46vi.mock("expo", async () => {
47 const { useSyncExternalStore } = await import("react");
48 return {
49 requireOptionalNativeModule: () => null,
50 useEvent: (player: ReturnType<typeof createPlayer>, event: string, initial: unknown) => {
51 const playing = useSyncExternalStore(player.subscribe, () => player.playing);
52 return event === "playingChange" ? { isPlaying: playing } : initial;
53 },
54 };
55});
56vi.mock("expo-video", async () => {
57 const { useState } = await import("react");
58 return {
59 useVideoPlayer: (
60 _source: unknown,
61 initialize: (player: ReturnType<typeof createPlayer>) => void,
62 ) =>
63 useState(() => {
64 const player = createPlayer();
65 initialize(player);
66 mocks.players.push(player);
67 return player;
68 })[0],
69 VideoView: (props: { onFullscreenEnter: () => void; onFullscreenExit: () => void }) => (
70 <div aria-label="Video">
71 <button aria-label="Enter fullscreen" onClick={props.onFullscreenEnter} />
72 <button aria-label="Exit fullscreen" onClick={props.onFullscreenExit} />
73 </div>
74 ),
75 };
76});
77vi.mock("expo-audio", () => ({
78 AudioModule: { setAudioSessionOwner: vi.fn() },
79 setAudioModeAsync: vi.fn().mockResolvedValue(undefined),
80 setIsAudioActiveAsync: vi.fn().mockResolvedValue(undefined),
81}));
82vi.mock("expo-image", () => ({ Image: () => null }));
83vi.mock("../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
84vi.mock("../components/ui/AppSymbol", () => ({ SymbolView: () => null }));
85vi.mock("../components/ui/AppText", () => ({
86 AppText: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
87}));
88vi.mock("../components/MediaActionsMenu", () => ({
89 MediaActionsMenu: ({ children }: { children?: ReactNode }) => children,
90}));
91vi.mock("../components/ui/NativePresentation", () => ({
92 PresentationSource: ({ children }: { children?: ReactNode }) => children,
93}));
94vi.mock("../state/mediaActions", () => ({
95 useMediaActions: () => ({ actions: [], sharing: false }),
96}));
97vi.mock("../state/assets", () => ({
98 useAssetUrlState: () => ({ _tag: "Success", url: "https://media.example/clip.mp4" }),
99 useRefreshAssetUrl: () => mocks.refresh,
100}));
101vi.mock("./localAttachmentPreview", () => ({ loadLocalAttachmentPreview: vi.fn() }));
102vi.mock("./videoThumbnails", () => ({
103 cachedVideoThumbnail: () => null,
104 loadVideoThumbnail: mocks.thumbnail,
105}));
106vi.mock("../components/ui/FilePreviewGallery", () => ({
107 useFilePreviewGallery: () => (value: unknown) => value,
108}));
109vi.mock("../components/ui/FilePreviewModal", () => ({ FilePreviewModal: () => null }));
110vi.mock("../components/ui/VideoPreviewModal", () => ({ VideoPreviewModal: () => null }));
111vi.mock("../features/threads/ThreadMarkdownImage", () => ({ ThreadMarkdownImageView: () => null }));
112​
113import {
114 ThreadMarkdownVideo,
115 ThreadMediaVisibleContext,
116} from "../features/threads/ThreadMarkdownVideo";
117import {
118 PullRequestMedia,
119 PullRequestMediaContext,
120 PullRequestMediaVisibleContext,
121} from "../features/pull-requests/PullRequestMedia";
122import { MediaVideoPlayer } from "../components/MediaVideoPlayer";
123​
124function createPlayer() {
125 let playing = false;
126 const listeners = new Set<() => void>();
127 const playingListeners = new Set<(event: { isPlaying: boolean }) => void>();
128 const setPlaying = (next: boolean) => {
129 playing = next;
130 for (const listener of listeners) listener();
131 for (const listener of playingListeners) listener({ isPlaying: next });
132 };
133 return {
134 currentTime: 0,
135 status: "readyToPlay",
136 get playing() {
137 return playing;
138 },
139 play: vi.fn(() => setPlaying(true)),
140 pause: vi.fn(() => setPlaying(false)),
141 replaceAsync: vi.fn(() => mocks.replace()),
142 addListener(event: string, listener: (event: { isPlaying: boolean }) => void) {
143 if (event === "playingChange") playingListeners.add(listener);
144 return { remove: () => playingListeners.delete(listener) };
145 },
146 subscribe(listener: () => void) {
147 listeners.add(listener);
148 return () => listeners.delete(listener);
149 },
150 };
152​
153function setup(kind: "thread" | "PR" | "files", compact = false) {
154 (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
155 mocks.focused = true;
156 mocks.appState = "active";
157 mocks.players = [];
158 mocks.appStateListeners.clear();
159 mocks.refresh.mockReset().mockResolvedValue("https://media.example/clip.mp4");
160 mocks.replace.mockReset().mockResolvedValue();
161 mocks.thumbnail.mockReset().mockResolvedValue(null);
162 const container = document.createElement("div");
163 const root = createRoot(container);
164 // Configured like the app's registry: the React default keeps unmounted atoms for 400ms,
165 // which would hold thumbnail loads open across renders and tests.
166 const registry = AtomRegistry.make();
167 const environmentId = EnvironmentId.make("test");
168 return {
169 render(visible = true) {
170 if (kind === "files") mocks.focused = visible;
171 act(() =>
172 root.render(
173 <RegistryContext.Provider value={registry}>
174 {kind === "files" ? (
175 <MediaVideoPlayer
176 name="clip.mp4"
177 thumbnailKey="workspace-video:test"
178 uri="https://media.example/clip.mp4"
179 fitToVideo
180 visible={visible}
181 resolvePlaybackUri={mocks.refresh}
182 />
183 ) : kind === "thread" ? (
184 <ThreadMediaVisibleContext value={visible}>
185 <ThreadMarkdownVideo
186 source={{
187 type: "media",
188 name: "clip.mp4",
189 mimeType: "video/mp4",
190 environmentId,
191 resource: {
192 _tag: "github-media",
193 cwd: "/project",
194 url: "https://github.com/user-attachments/assets/video",
195 },
196 }}
197 />
198 </ThreadMediaVisibleContext>
199 ) : (
200 <PullRequestMediaContext value={{ environmentId, cwd: "/project" }}>
201 <PullRequestMediaVisibleContext value={visible}>
202 <PullRequestMedia
203 url="https://github.com/user-attachments/assets/video"
204 kind="video"
205 compact={compact}
206 />
207 </PullRequestMediaVisibleContext>
208 </PullRequestMediaContext>
209 )}
210 </RegistryContext.Provider>,
211 ),
212 );
213 },
214 async open() {
215 const button = container.querySelector("button");
216 expect(button).not.toBeNull();
217 await act(async () => button!.click());
218 expect(mocks.players).toHaveLength(1);
219 return mocks.players[0]!;
220 },
221 fullscreen(open: boolean) {
222 const button = container.querySelector<HTMLButtonElement>(
223 `[aria-label="${open ? "Enter" : "Exit"} fullscreen"]`,
224 );
225 expect(button).not.toBeNull();
226 act(() => button!.click());
227 },
228 appState(state: AppStateStatus) {
229 mocks.appState = state;
230 act(() => {
231 for (const listener of mocks.appStateListeners) listener(state);
232 });
233 },
234 [Symbol.dispose]() {
235 act(() => root.unmount());
236 registry.dispose();
237 },
238 };
240​
241it.each(["thread", "PR"] as const)(
242 "retains the %s player and position while hidden and requires deliberate resume",
243 async (kind) => {
244 using screen = setup(kind);
245 screen.render();
246 const player = await screen.open();
247 expect(player.playing).toBe(true);
248 player.currentTime = 37;
249 screen.render(false);
250 expect(player.playing).toBe(false);
251 screen.render(true);
252 expect(mocks.players).toEqual([player]);
253 expect(player.replaceAsync).toHaveBeenCalledTimes(1);
254 expect(player.currentTime).toBe(37);
255 expect(player.playing).toBe(false);
256 },
257);
258​
259it.each(["thread", "PR"] as const)(
260 "does not start a hidden %s video when signing or native loading finishes late",
261 async (kind) => {
262 using screen = setup(kind);
263 const signed = Promise.withResolvers<string | null>();
264 const replaced = Promise.withResolvers<void>();
265 mocks.refresh.mockReturnValue(signed.promise);
266 mocks.replace.mockReturnValue(replaced.promise);
267 screen.render();
268 const player = await screen.open();
269 screen.render(false);
270 await act(async () => signed.resolve("https://media.example/renewed.mp4"));
271 expect(player.replaceAsync).toHaveBeenCalledTimes(1);
272 expect(player.play).not.toHaveBeenCalled();
273 await act(async () => replaced.resolve());
274 expect(player.play).not.toHaveBeenCalled();
275 screen.render(true);
276 expect(player.playing).toBe(false);
277 expect(player.replaceAsync).toHaveBeenCalledTimes(1);
278 },
279);
280​
281it.each([
282 ["PR", false],
283 ["PR", true],
284 ["files", false],
285] as const)(
286 "stops inactive %s thumbnail requests (compact: %s) and restarts on return",
287 async (kind, compact) => {
288 using screen = setup(kind, compact);
289 mocks.thumbnail.mockReturnValue(new Promise(() => {}));
290 screen.render(false);
291 expect(mocks.thumbnail).not.toHaveBeenCalled();
292 await act(async () => screen.render(true));
293 const requests = kind === "files" ? 2 : 1;
294 expect(mocks.thumbnail).toHaveBeenCalledTimes(requests);
295 const aborted = Promise.all(
296 mocks.thumbnail.mock.calls.map(([, , signal]) => {
297 expect(signal.aborted).toBe(false);
298 return new Promise((resolve) => signal.addEventListener("abort", resolve));
299 }),
300 );
301 screen.render(false);
302 await act(() => aborted);
303 expect(mocks.thumbnail).toHaveBeenCalledTimes(requests);
304 await act(async () => screen.render(true));
305 expect(mocks.thumbnail).toHaveBeenCalledTimes(requests * 2);
306 },
307);
308​
309it("pauses on screen blur and app inactivity without resuming on return", async () => {
310 using screen = setup("thread");
311 screen.render();
312 const player = await screen.open();
313 for (const reason of ["blur", "inactive", "background"] as const) {
314 act(() => player.play());
315 player.currentTime = 37;
316 if (reason === "blur") {
317 mocks.focused = false;
318 screen.render();
319 } else screen.appState(reason);
320 expect(player.playing, reason).toBe(false);
321 mocks.focused = true;
322 screen.appState("active");
323 screen.render();
324 expect(player.playing, reason).toBe(false);
325 expect(player.currentTime).toBe(37);
326 }
327 expect(player.replaceAsync).toHaveBeenCalledTimes(1);
328});
329​
330it.each(["thread", "PR"] as const)(
331 "keeps the %s play request cancelled when loading finishes after returning",
332 async (kind) => {
333 using screen = setup(kind);
334 const signed = Promise.withResolvers<string | null>();
335 const replaced = Promise.withResolvers<void>();
336 mocks.refresh.mockReturnValue(signed.promise);
337 mocks.replace.mockReturnValue(replaced.promise);
338 screen.render();
339 const player = await screen.open();
340 screen.render(false);
341 screen.render(true);
342 await act(async () => signed.resolve("https://media.example/renewed.mp4"));
343 await act(async () => replaced.resolve());
344 expect(player.replaceAsync).toHaveBeenCalledTimes(1);
345 expect(player.play).not.toHaveBeenCalled();
346 act(() => player.play());
347 expect(player.playing).toBe(true);
348 },
349);
350​
351it("pauses when native fullscreen exits while the app remains in the background", async () => {
352 using screen = setup("thread");
353 screen.render();
354 const player = await screen.open();
355 player.currentTime = 37;
356 screen.fullscreen(true);
357 screen.appState("background");
358 expect(player.playing).toBe(true);
359 screen.fullscreen(false);
360 expect(player.playing).toBe(false);
361 screen.appState("active");
362 expect(player.playing).toBe(false);
363 expect(player.currentTime).toBe(37);
364});