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