188 lines
5.9 KiB
TypeScript
188 lines
5.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
BrowserMetadataError,
|
|
browserMediaFileInfo,
|
|
inferBrowserMediaKind,
|
|
inspectBrowserMediaMetadata,
|
|
} from '../../../src/media/browser-metadata';
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('browser media metadata', () => {
|
|
it('returns file information synchronously and infers useful media kinds', () => {
|
|
const file = new File(['media'], 'lecture.MP4', {
|
|
type: 'application/octet-stream',
|
|
lastModified: 123,
|
|
});
|
|
|
|
expect(browserMediaFileInfo(file)).toEqual({
|
|
name: 'lecture.MP4',
|
|
sizeBytes: 5,
|
|
declaredMimeType: 'application/octet-stream',
|
|
inferredMimeType: 'video/mp4',
|
|
lastModified: 123,
|
|
});
|
|
expect(inferBrowserMediaKind(file)).toBe('video');
|
|
expect(
|
|
inferBrowserMediaKind(
|
|
new File([], 'recording.bin', { type: 'audio/ogg; codecs=opus' })
|
|
)
|
|
).toBe('audio');
|
|
expect(inferBrowserMediaKind(new File([], 'notes.txt'))).toBe('unknown');
|
|
});
|
|
|
|
it('loads duration and dimensions through a native video element', async () => {
|
|
const file = new File(['video'], 'lecture.mp4', { type: 'video/mp4' });
|
|
const video = document.createElement('video');
|
|
const load = vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
|
defineNumber(video, 'duration', 2_622.48);
|
|
defineNumber(video, 'videoWidth', 640);
|
|
defineNumber(video, 'videoHeight', 480);
|
|
Object.defineProperty(video, 'audioTracks', {
|
|
configurable: true,
|
|
value: { length: 1 },
|
|
});
|
|
|
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:lecture', {
|
|
createMediaElement: () => video,
|
|
});
|
|
video.dispatchEvent(new Event('loadedmetadata'));
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
source: 'browser',
|
|
file: {
|
|
name: 'lecture.mp4',
|
|
sizeBytes: 5,
|
|
declaredMimeType: 'video/mp4',
|
|
inferredMimeType: 'video/mp4',
|
|
lastModified: file.lastModified,
|
|
},
|
|
kind: 'video',
|
|
durationSeconds: 2_622.48,
|
|
width: 640,
|
|
height: 480,
|
|
hasAudio: true,
|
|
hasVideo: true,
|
|
});
|
|
expect(video.preload).toBe('metadata');
|
|
expect(video).not.toHaveAttribute('src');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('uses an audio element and leaves unavailable details unknown', async () => {
|
|
const file = new File(['audio'], 'speech.flac', { type: 'audio/flac' });
|
|
const audio = document.createElement('audio');
|
|
vi.spyOn(audio, 'load').mockImplementation(() => undefined);
|
|
defineNumber(audio, 'duration', 12.5);
|
|
|
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:speech', {
|
|
createMediaElement: (kind) => {
|
|
expect(kind).toBe('audio');
|
|
return audio;
|
|
},
|
|
});
|
|
audio.dispatchEvent(new Event('loadedmetadata'));
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
source: 'browser',
|
|
kind: 'audio',
|
|
durationSeconds: 12.5,
|
|
hasAudio: true,
|
|
hasVideo: false,
|
|
});
|
|
});
|
|
|
|
it('keeps a video-container player viable when dimensions and audio hints are absent', async () => {
|
|
const file = new File(['audio-only'], 'audio-only.mp4', {
|
|
type: 'video/mp4',
|
|
});
|
|
const video = document.createElement('video');
|
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
|
defineNumber(video, 'duration', 8);
|
|
defineNumber(video, 'videoWidth', 0);
|
|
defineNumber(video, 'videoHeight', 0);
|
|
Object.defineProperty(video, 'audioTracks', {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
|
|
const resultPromise = inspectBrowserMediaMetadata(file, 'blob:audio-only', {
|
|
createMediaElement: () => video,
|
|
});
|
|
video.dispatchEvent(new Event('loadedmetadata'));
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
kind: 'video',
|
|
durationSeconds: 8,
|
|
hasVideo: undefined,
|
|
hasAudio: undefined,
|
|
});
|
|
});
|
|
|
|
it('reports load errors without revoking the caller-owned object URL', async () => {
|
|
const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL');
|
|
const video = document.createElement('video');
|
|
vi.spyOn(video, 'load').mockImplementation(() => undefined);
|
|
const resultPromise = inspectBrowserMediaMetadata(
|
|
new File([], 'broken.mp4', { type: 'video/mp4' }),
|
|
'blob:broken',
|
|
{ createMediaElement: () => video }
|
|
);
|
|
|
|
video.dispatchEvent(new Event('error'));
|
|
|
|
await expect(resultPromise).rejects.toMatchObject({
|
|
name: 'BrowserMetadataError',
|
|
code: 'load-error',
|
|
});
|
|
expect(revokeObjectUrl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('supports cancellation and a bounded metadata timeout', async () => {
|
|
const file = new File([], 'long.mp4', { type: 'video/mp4' });
|
|
const controller = new AbortController();
|
|
const firstVideo = document.createElement('video');
|
|
vi.spyOn(firstVideo, 'load').mockImplementation(() => undefined);
|
|
const cancelled = inspectBrowserMediaMetadata(file, 'blob:cancel', {
|
|
createMediaElement: () => firstVideo,
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
|
|
await expect(cancelled).rejects.toMatchObject({
|
|
code: 'aborted',
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const secondVideo = document.createElement('video');
|
|
vi.spyOn(secondVideo, 'load').mockImplementation(() => undefined);
|
|
const timedOut = inspectBrowserMediaMetadata(file, 'blob:timeout', {
|
|
createMediaElement: () => secondVideo,
|
|
timeoutMilliseconds: 25,
|
|
});
|
|
const timeoutExpectation = expect(timedOut).rejects.toEqual(
|
|
new BrowserMetadataError(
|
|
'timeout',
|
|
'Browser metadata loading exceeded 1 seconds.'
|
|
)
|
|
);
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
await timeoutExpectation;
|
|
});
|
|
});
|
|
|
|
function defineNumber(
|
|
target: HTMLMediaElement,
|
|
property: 'duration' | 'videoHeight' | 'videoWidth',
|
|
value: number
|
|
): void {
|
|
Object.defineProperty(target, property, {
|
|
configurable: true,
|
|
value,
|
|
});
|
|
}
|