Files
av-tools/tests/unit/media/browser-metadata.test.ts

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,
});
}