mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
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.
584 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|