feat: make media inspection progressive
This commit is contained in:
187
tests/unit/media/browser-metadata.test.ts
Normal file
187
tests/unit/media/browser-metadata.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user