Files
OpenFrame/tests/component/hooks/use-video-player.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
Second pass over the suite, driven by the inventory in the gaps document. Nine
agents wrote suites in parallel against private databases, then a tenth read all
of it adversarially and five of its findings were fixed.

  unit + component  2076 -> 2079 (+888 over the round)
  api                647 -> 1015
  e2e                 18 -> 29

What was closed:

- lib/route-access.ts, the page-level authorization layer, went from zero tests
  to 48. Every API route was guarded and none of the pages were.
- The five media proxy routes now have a real 2xx beside every 403. The blocker
  was the positive control, solved by stubbing r2Client.send() and leaving
  lib/r2-media-proxy.ts itself real.
- Every remaining server-side lib module: invitations, email verification, the
  upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle,
  notifications and admin stats.
- Six video-page hooks, and the chunking arithmetic extracted out of
  lib/client/r2-video-upload.ts as a pure module.
- Five end-to-end flows: workspace members, bulk operations, the admin area,
  player interaction and failure recovery.

Three things about the harness itself turned out to be wrong:

- Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every
  route reaching finalizeR2VideoUpload silently took the "not a valid video"
  branch and no test noticed.
- The auth matrix asserted only "not 2xx", which two entries satisfied without
  their guard existing. It now requires 401 or 403, which makes both
  load-bearing, and all 60 routes pass the stricter form.
- Both admin API routes had no positive control anywhere: replacing their guard
  with an unconditional refusal left the entire suite green. Found by the
  adversarial review, now covered.

Process:

- bun run test:mutation runs StrykerJS over the authorization and validation
  modules. Diagnostic, not a gate, weekly in CI rather than on a push.
- playwright.config.ts gains an opt-in webkit project for the player spec.
- AGENTS.md now requires a batch of new tests to be reviewed by somebody who
  did not write them.

Only two production files change, both deliberate: lib/auth.ts loses a verbatim
copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the
extracted arithmetic. No behaviour change in either.
2026-07-26 13:25:11 +07:00

584 lines
18 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useVideoPlayer } from '@/components/video-page/hooks/use-video-player';
import type { PlayerAdapter, Version } from '@/components/video-page/types';
type Params = Parameters<typeof useVideoPlayer>[0];
/** Measured from the video element's metadata, so every seek clamps to it. */
const DURATION = 60;
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2];
/** The timeline the tests drag over: 100px wide, starting at the viewport edge. */
const TIMELINE_LEFT = 0;
const TIMELINE_WIDTH = 100;
/** The hook builds the player inside a 100ms timeout. */
const PLAYER_INIT_DELAY_MS = 100;
type FrameMetadata = { mediaTime: number; presentedFrames: number };
type FrameCallback = (now: number, metadata: FrameMetadata) => void;
/**
* A stand-in for the HTMLVideoElement the R2 branch of the hook drives. jsdom
* has no media pipeline at all: it never fires 'play' or 'loadedmetadata', and
* `duration` is a read-only NaN. This object exposes only the surface the hook
* touches, and lets a test fire the media events itself so the timing is
* explicit rather than accidental.
*/
function createVideoStub() {
const listeners = new Map<string, Set<() => void>>();
let frameCallback: FrameCallback | null = null;
let nextFrameCallbackId = 1;
const video = {
currentTime: 0,
duration: DURATION,
paused: true,
muted: false,
playbackRate: 1,
seeking: false,
videoWidth: 1920,
videoHeight: 1080,
readyState: 2,
src: '',
play: vi.fn(() => {
video.paused = false;
return Promise.resolve();
}),
pause: vi.fn(() => {
video.paused = true;
}),
load: vi.fn(),
removeAttribute: vi.fn(),
addEventListener: (type: string, handler: () => void) => {
const forType = listeners.get(type) ?? new Set<() => void>();
forType.add(handler);
listeners.set(type, forType);
},
removeEventListener: (type: string, handler: () => void) => {
listeners.get(type)?.delete(handler);
},
requestVideoFrameCallback: vi.fn((callback: FrameCallback) => {
frameCallback = callback;
return nextFrameCallbackId++;
}),
cancelVideoFrameCallback: vi.fn(() => {
frameCallback = null;
}),
/** Deliver a media event to whatever the hook has subscribed. */
fire: (type: string) => {
for (const handler of [...(listeners.get(type) ?? [])]) handler();
},
/** Deliver one presented-frame sample to the frame-rate tracker. */
emitFrame: (metadata: FrameMetadata) => {
const callback = frameCallback;
frameCallback = null;
callback?.(0, metadata);
},
};
return video;
}
type VideoStub = ReturnType<typeof createVideoStub>;
function makeVersion(): Version {
return {
id: 'ver1',
versionNumber: 1,
versionLabel: null,
providerId: 'r2',
videoId: 'vid1',
originalUrl: '/api/upload/video/abc.mp4',
title: null,
thumbnailUrl: null,
// Left unset so the duration under test is the one measured from the
// element, which is what a real page ends up using.
duration: null,
isActive: true,
_count: { comments: 0 },
};
}
function makeTimeline(): HTMLDivElement {
const timeline = document.createElement('div');
// jsdom does no layout, so every rect is zero unless we supply one.
timeline.getBoundingClientRect = () =>
({ left: TIMELINE_LEFT, width: TIMELINE_WIDTH }) as DOMRect;
document.body.appendChild(timeline);
return timeline;
}
function renderPlayer() {
const video = createVideoStub();
const timeline = makeTimeline();
const readout = document.createElement('div');
const playerRef: { current: PlayerAdapter | null } = { current: null };
const params: Params = {
activeVersion: makeVersion(),
activeVersionId: 'ver1',
activeProviderId: 'r2',
embedUrl: '/api/upload/video/abc.mp4',
canInitializePlayer: true,
iframeRef: { current: null },
videoRef: { current: video as unknown as HTMLVideoElement },
bunnyViewportRef: { current: null },
timelineRef: { current: timeline },
progressRef: { current: document.createElement('div') },
playheadRef: { current: document.createElement('div') },
scrubReadoutRef: { current: readout },
hlsRef: { current: null },
playerRef,
formatTime: (seconds: number) => `${Math.floor(seconds)}s`,
formatBunnyQualityLabel: () => 'auto',
speedOptions: SPEED_OPTIONS,
scheduleWatchProgressSaveRef: { current: vi.fn() },
setViewingAnnotation: vi.fn(),
};
const rendered = renderHook(() => useVideoPlayer(params));
act(() => {
vi.advanceTimersByTime(PLAYER_INIT_DELAY_MS);
});
// Without metadata the hook has no duration, so nothing would clamp.
act(() => {
video.fire('loadedmetadata');
});
return { ...rendered, video, timeline, readout };
}
/** Put the player into the playing state the way the media element would. */
function startPlayback(video: VideoStub) {
act(() => {
video.paused = false;
video.fire('play');
});
}
function stopPlayback(video: VideoStub) {
act(() => {
video.paused = true;
video.fire('pause');
});
}
/**
* Two presented-frame samples one second apart is what the hook needs to derive
* a rate; the first sample only establishes a baseline.
*/
function measureFrameRate(video: VideoStub, fps: number) {
act(() => {
video.emitFrame({ mediaTime: 0, presentedFrames: 0 });
});
act(() => {
video.emitFrame({ mediaTime: 1, presentedFrames: fps });
});
}
function pressKey(
code: string,
options: { shiftKey?: boolean; target?: EventTarget } = {}
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
code,
shiftKey: options.shiftKey ?? false,
bubbles: true,
cancelable: true,
});
act(() => {
(options.target ?? window).dispatchEvent(event);
});
return event;
}
function mouseEventAt(clientX: number) {
return { clientX } as React.MouseEvent<HTMLDivElement>;
}
beforeEach(() => {
vi.useFakeTimers();
// The hook injects the YouTube iframe API before the first <script> on the
// page. Next always renders one; jsdom renders none, and the hook would
// dereference undefined.
document.head.appendChild(document.createElement('script'));
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
window.onYouTubeIframeAPIReady = undefined;
document.head.innerHTML = '';
document.body.innerHTML = '';
});
describe('useVideoPlayer seeking', () => {
it('takes its duration from the loaded metadata', () => {
const { result } = renderPlayer();
expect(result.current.isReady).toBe(true);
expect(result.current.videoDuration).toBe(DURATION);
});
it('clamps a backwards skip at the start of the video', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(3));
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBe(0);
expect(video.currentTime).toBe(0);
});
it('clamps a forwards skip at the end of the video', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(58));
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBe(DURATION);
expect(video.currentTime).toBe(DURATION);
});
it('seeks by the requested amount away from the ends', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBe(25);
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBe(20);
expect(video.currentTime).toBe(20);
});
it('leaves a paused video paused after a seek, and a playing one playing', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSkip(5));
expect(video.pause).toHaveBeenCalled();
expect(video.play).not.toHaveBeenCalled();
startPlayback(video);
act(() => result.current.handleSkip(5));
expect(video.play).toHaveBeenCalled();
});
});
describe('useVideoPlayer frame stepping', () => {
it('derives the frame rate from presented-frame samples', () => {
const { result, video } = renderPlayer();
startPlayback(video);
expect(result.current.frameStepLabel).toBe('1s');
measureFrameRate(video, 25);
expect(result.current.frameStepSeconds).toBe(0.04);
expect(result.current.frameStepLabel).toBe('1f');
});
it('ignores samples taken across a seek', () => {
// Frames presented either side of a seek come from two different points in
// the timeline, so their ratio is not a frame rate.
const { result, video } = renderPlayer();
startPlayback(video);
video.seeking = true;
measureFrameRate(video, 25);
expect(result.current.frameStepLabel).toBe('1s');
});
it('moves exactly one frame per step once a rate is known', () => {
const { result, video } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
stopPlayback(video);
act(() => result.current.handleFrameModeToggle());
act(() => result.current.handleSeekToTimestamp(10));
// A 5-second skip request collapses to a single 1/25s frame.
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
expect(video.currentTime).toBeCloseTo(10.04, 10);
act(() => result.current.handleSkip(5));
expect(result.current.currentTime).toBeCloseTo(10.08, 10);
act(() => result.current.handleSkip(-5));
expect(result.current.currentTime).toBeCloseTo(10.04, 10);
});
it('steps a whole second while no frame rate has been measured', () => {
const { result } = renderPlayer();
act(() => result.current.handleFrameModeToggle());
act(() => result.current.handleSeekToTimestamp(10));
act(() => result.current.handleSkip(5));
expect(result.current.frameStepLabel).toBe('1s');
expect(result.current.currentTime).toBe(11);
});
it('skips the full requested amount while frame mode is off', () => {
const { result, video } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
act(() => result.current.handleSeekToTimestamp(10));
act(() => result.current.handleSkip(5));
expect(result.current.isFrameMode).toBe(false);
expect(result.current.currentTime).toBe(15);
});
});
describe('useVideoPlayer scrubbing', () => {
it('seeks to the fraction of the duration the pointer landed on', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
expect(result.current.isDragging).toBe(true);
expect(result.current.currentTime).toBe(DURATION / 2);
// The drag previews live, so the element is seeked before release.
expect(video.currentTime).toBe(DURATION / 2);
});
it('clamps a drag dragged off either end of the timeline', () => {
const { result } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(-500)));
expect(result.current.currentTime).toBe(0);
act(() => result.current.handleTimelineMouseMove(mouseEventAt(5000)));
expect(result.current.currentTime).toBe(DURATION);
});
it('tracks the pointer even when it leaves the timeline', () => {
const { result } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
act(() => {
window.dispatchEvent(new MouseEvent('mousemove', { clientX: 75 }));
});
expect(result.current.currentTime).toBe(45);
});
it('commits the final position to the video element on release', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(10)));
act(() => result.current.handleTimelineMouseMove(mouseEventAt(90)));
act(() => result.current.handleTimelineMouseUp());
expect(result.current.isDragging).toBe(false);
expect(result.current.currentTime).toBe(54);
expect(video.currentTime).toBe(54);
});
it('freezes playback for the length of the drag and resumes it after', () => {
const { result, video } = renderPlayer();
startPlayback(video);
video.play.mockClear();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
expect(video.pause).toHaveBeenCalled();
expect(video.play).not.toHaveBeenCalled();
act(() => result.current.handleTimelineMouseUp());
expect(video.play).toHaveBeenCalled();
});
it('leaves a paused video paused after a drag', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleTimelineMouseDown(mouseEventAt(50)));
act(() => result.current.handleTimelineMouseUp());
expect(video.play).not.toHaveBeenCalled();
});
it('shows the frame number under the cursor while dragging', () => {
const { result, video, readout } = renderPlayer();
startPlayback(video);
measureFrameRate(video, 25);
stopPlayback(video);
act(() => result.current.handleTimelineMouseDown(mouseEventAt(TIMELINE_WIDTH / 2)));
// Halfway through a 60s clip at 25fps is second 30, frame 750.
expect(readout.textContent).toBe('30s · f750');
expect(result.current.showScrubReadout).toBe(true);
});
});
describe('useVideoPlayer keyboard shortcuts', () => {
it('starts and stops playback on space', () => {
const { video } = renderPlayer();
const first = pressKey('Space');
expect(video.play).toHaveBeenCalledTimes(1);
// Otherwise the page scrolls under the player.
expect(first.defaultPrevented).toBe(true);
startPlayback(video);
pressKey('Space');
expect(video.pause).toHaveBeenCalledTimes(1);
});
it('treats K the same as space', () => {
const { video } = renderPlayer();
pressKey('KeyK');
expect(video.play).toHaveBeenCalledTimes(1);
});
it('skips five seconds with the left and right arrows', () => {
const { result } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
pressKey('ArrowRight');
expect(result.current.currentTime).toBe(25);
pressKey('ArrowLeft');
expect(result.current.currentTime).toBe(20);
});
it('jumps ten seconds with J and L, clamped to the media', () => {
const { result, video } = renderPlayer();
act(() => result.current.handleSeekToTimestamp(20));
pressKey('KeyL');
expect(result.current.currentTime).toBe(30);
expect(video.currentTime).toBe(30);
pressKey('KeyJ');
expect(result.current.currentTime).toBe(20);
act(() => result.current.handleSeekToTimestamp(5));
pressKey('KeyJ');
expect(result.current.currentTime).toBe(0);
act(() => result.current.handleSeekToTimestamp(55));
pressKey('KeyL');
expect(result.current.currentTime).toBe(DURATION);
});
it('toggles mute on the element with M', () => {
const { result, video } = renderPlayer();
pressKey('KeyM');
expect(video.muted).toBe(true);
expect(result.current.isMuted).toBe(true);
pressKey('KeyM');
expect(video.muted).toBe(false);
expect(result.current.isMuted).toBe(false);
});
it('steps the speed ladder with the up and down arrows, stopping at the ends', () => {
const { result, video } = renderPlayer();
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(1.5);
expect(video.playbackRate).toBe(1.5);
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(2);
// 2x is the top of the ladder: the shortcut must not wrap around.
pressKey('ArrowUp');
expect(result.current.playbackSpeed).toBe(2);
pressKey('ArrowDown');
expect(result.current.playbackSpeed).toBe(1.5);
expect(video.playbackRate).toBe(1.5);
});
it('steps the speed ladder with shifted comma and period', () => {
const { result } = renderPlayer();
pressKey('Period', { shiftKey: true });
expect(result.current.playbackSpeed).toBe(1.5);
pressKey('Comma', { shiftKey: true });
expect(result.current.playbackSpeed).toBe(1);
});
it('leaves an unshifted comma alone so it can still be typed', () => {
const { result } = renderPlayer();
const event = pressKey('Comma');
expect(result.current.playbackSpeed).toBe(1);
expect(event.defaultPrevented).toBe(false);
});
it('requests fullscreen with F', async () => {
const { result } = renderPlayer();
const requestFullscreen = vi.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = requestFullscreen;
pressKey('KeyF');
await act(async () => {});
expect(requestFullscreen).toHaveBeenCalledTimes(1);
expect(result.current.isFullscreenMode).toBe(true);
// Fullscreen is for watching, so the comments pane gets out of the way.
expect(result.current.showComments).toBe(false);
});
it('ignores a shortcut typed into a text field', () => {
const { result, video } = renderPlayer();
const input = document.createElement('input');
document.body.appendChild(input);
act(() => result.current.handleSeekToTimestamp(20));
const space = pressKey('Space', { target: input });
pressKey('ArrowRight', { target: input });
expect(video.play).not.toHaveBeenCalled();
expect(result.current.currentTime).toBe(20);
// Nothing was claimed, so the keystroke still reaches the field.
expect(space.defaultPrevented).toBe(false);
});
it('ignores a shortcut typed into a rich text editor', () => {
const { video } = renderPlayer();
const editor = document.createElement('div');
editor.contentEditable = 'true';
// jsdom does not derive isContentEditable from the attribute.
Object.defineProperty(editor, 'isContentEditable', { value: true });
document.body.appendChild(editor);
pressKey('Space', { target: editor });
expect(video.play).not.toHaveBeenCalled();
});
it('ignores every shortcut while a dialog is open', () => {
const { result, video } = renderPlayer();
const dialog = document.createElement('div');
dialog.setAttribute('data-slot', 'dialog-content');
document.body.appendChild(dialog);
act(() => result.current.handleSeekToTimestamp(20));
pressKey('Space');
pressKey('ArrowRight');
pressKey('KeyM');
expect(video.play).not.toHaveBeenCalled();
expect(result.current.currentTime).toBe(20);
expect(video.muted).toBe(false);
});
});