z-web-audio-stream
Version:
iOS Safari-safe Web Audio streaming with separated download/storage optimization, instant playback, and memory management
430 lines (342 loc) • 15.8 kB
text/typescript
// DownloadManager.test.ts
// Unit tests for the DownloadManager class with mock HTTP requests
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DownloadManager, type DownloadManagerOptions, type DownloadProgress, type DownloadChunk } from '../DownloadManager.js';
// Mock fetch globally
global.fetch = vi.fn();
describe('DownloadManager', () => {
let downloadManager: DownloadManager;
let mockFetch: ReturnType<typeof vi.fn>;
let progressCallback: ReturnType<typeof vi.fn>;
let chunkCallback: ReturnType<typeof vi.fn>;
let completeCallback: ReturnType<typeof vi.fn>;
let errorCallback: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.mocked(fetch);
progressCallback = vi.fn();
chunkCallback = vi.fn();
completeCallback = vi.fn();
errorCallback = vi.fn();
// Reset all mocks
vi.clearAllMocks();
});
afterEach(() => {
downloadManager?.cleanup();
});
const createMockResponse = (data: ArrayBuffer, status = 200, headers: Record<string, string> = {}) => {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
headers: new Map(Object.entries({
'content-length': data.byteLength.toString(),
'accept-ranges': 'bytes',
...headers
})),
arrayBuffer: () => Promise.resolve(data),
} as Response);
};
const createTestAudioData = (size: number): ArrayBuffer => {
const buffer = new ArrayBuffer(size);
const view = new Uint8Array(buffer);
// Fill with some test pattern
for (let i = 0; i < size; i++) {
view[i] = i % 256;
}
return buffer;
};
describe('Constructor and Configuration', () => {
it('should initialize with default strategy', () => {
downloadManager = new DownloadManager();
expect(downloadManager).toBeDefined();
});
it('should apply custom strategy options', () => {
const customStrategy = {
initialChunkSize: 128 * 1024,
standardChunkSize: 512 * 1024,
maxConcurrentDownloads: 2,
priorityFirstChunk: false,
adaptiveChunkSizing: false
};
downloadManager = new DownloadManager({
strategy: customStrategy,
onProgress: progressCallback
});
expect(downloadManager).toBeDefined();
});
it('should detect iOS Safari and adjust strategy', () => {
// Mock iOS Safari user agent
Object.defineProperty(navigator, 'userAgent', {
writable: true,
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1'
});
downloadManager = new DownloadManager();
expect(downloadManager).toBeDefined();
// iOS Safari should have smaller chunk sizes and fewer concurrent downloads
});
});
describe('Range Request Support Detection', () => {
it('should detect range request support from server headers', async () => {
const testData = createTestAudioData(1024 * 1024); // 1MB
mockFetch.mockResolvedValueOnce(createMockResponse(testData.slice(0, 1024), 206, {
'accept-ranges': 'bytes',
'content-range': 'bytes 0-1023/1048576'
}));
downloadManager = new DownloadManager();
const supportsRange = await downloadManager.checkRangeRequestSupport('https://example.com/audio.mp3');
expect(supportsRange).toBe(true);
expect(mockFetch).toHaveBeenCalledWith('https://example.com/audio.mp3', {
method: 'GET',
headers: { 'Range': 'bytes=0-1023' }
});
});
it('should handle servers that do not support range requests', async () => {
const testData = createTestAudioData(1024);
mockFetch.mockResolvedValueOnce(createMockResponse(testData, 200, {
'accept-ranges': 'none'
}));
downloadManager = new DownloadManager();
const supportsRange = await downloadManager.checkRangeRequestSupport('https://example.com/audio.mp3');
expect(supportsRange).toBe(false);
});
});
describe('Chunked Download Process', () => {
it('should download file in multiple chunks with range requests', async () => {
const fileSize = 512 * 1024; // 512KB file
const chunkSize = 128 * 1024; // 128KB chunks
const totalData = createTestAudioData(fileSize);
// Mock range request support check
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206, {
'accept-ranges': 'bytes',
'content-range': `bytes 0-1023/${fileSize}`
}));
// Mock chunked downloads
for (let i = 0; i < Math.ceil(fileSize / chunkSize); i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize - 1, fileSize - 1);
const chunkData = totalData.slice(start, end + 1);
mockFetch.mockResolvedValueOnce(createMockResponse(chunkData, 206, {
'content-range': `bytes ${start}-${end}/${fileSize}`
}));
}
downloadManager = new DownloadManager({
strategy: { initialChunkSize: chunkSize, standardChunkSize: chunkSize },
onProgress: progressCallback,
onChunkComplete: chunkCallback,
onComplete: completeCallback
});
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(chunks).toHaveLength(4); // 512KB / 128KB = 4 chunks
expect(progressCallback).toHaveBeenCalled();
expect(chunkCallback).toHaveBeenCalledTimes(4);
expect(completeCallback).toHaveBeenCalledOnce();
// Verify chunks have correct data
chunks.forEach((chunk, index) => {
expect(chunk.index).toBe(index);
expect(chunk.data).toBeInstanceOf(ArrayBuffer);
expect(chunk.downloadTime).toBeGreaterThan(0);
});
});
it('should handle priority first chunk download', async () => {
const fileSize = 256 * 1024; // 256KB file
const totalData = createTestAudioData(fileSize);
// Mock range support
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// Mock first chunk (priority)
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 64 * 1024), 206));
// Mock remaining chunks
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(64 * 1024), 206));
downloadManager = new DownloadManager({
strategy: {
initialChunkSize: 64 * 1024,
standardChunkSize: 192 * 1024,
priorityFirstChunk: true
},
onChunkComplete: chunkCallback
});
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(chunks).toHaveLength(2);
// First chunk should be completed first (priority)
const firstChunkCall = chunkCallback.mock.calls[0][0] as DownloadChunk;
expect(firstChunkCall.index).toBe(0);
expect(firstChunkCall.data.byteLength).toBe(64 * 1024);
});
it('should handle concurrent downloads with max limit', async () => {
const fileSize = 1024 * 1024; // 1MB file
const chunkSize = 128 * 1024; // 128KB chunks = 8 chunks
const maxConcurrent = 3;
const totalData = createTestAudioData(fileSize);
// Mock range support
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// Mock all chunk downloads
for (let i = 0; i < 8; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize - 1, fileSize - 1);
const chunkData = totalData.slice(start, end + 1);
mockFetch.mockResolvedValueOnce(createMockResponse(chunkData, 206));
}
downloadManager = new DownloadManager({
strategy: {
initialChunkSize: chunkSize,
standardChunkSize: chunkSize,
maxConcurrentDownloads: maxConcurrent
}
});
const startTime = Date.now();
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
const endTime = Date.now();
expect(chunks).toHaveLength(8);
// With proper concurrency, this should complete faster than sequential
expect(endTime - startTime).toBeLessThan(1000); // Should be fast with mocks
});
});
describe('Adaptive Chunk Sizing', () => {
it('should adapt chunk size based on connection speed', async () => {
const fileSize = 512 * 1024;
const totalData = createTestAudioData(fileSize);
// Mock slow connection (simulate delay)
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// Mock slow first chunk download
mockFetch.mockImplementation(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve(createMockResponse(totalData.slice(0, 64 * 1024), 206));
}, 100); // Simulate slow network
});
});
downloadManager = new DownloadManager({
strategy: {
adaptiveChunkSizing: true,
initialChunkSize: 64 * 1024,
standardChunkSize: 128 * 1024
}
});
// This would normally adapt chunk sizes based on measured speed
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(chunks.length).toBeGreaterThan(0);
});
});
describe('Progress Tracking', () => {
it('should report accurate progress during download', async () => {
const fileSize = 256 * 1024;
const chunkSize = 64 * 1024;
const totalData = createTestAudioData(fileSize);
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// Mock sequential chunk downloads
for (let i = 0; i < 4; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize - 1, fileSize - 1);
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(start, end + 1), 206));
}
downloadManager = new DownloadManager({
onProgress: progressCallback
});
await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(progressCallback).toHaveBeenCalled();
// Check that progress values make sense
const progressCalls = progressCallback.mock.calls.map(call => call[0] as DownloadProgress);
progressCalls.forEach(progress => {
expect(progress.bytesLoaded).toBeGreaterThanOrEqual(0);
expect(progress.bytesTotal).toBe(fileSize);
expect(progress.chunksCompleted).toBeGreaterThanOrEqual(0);
expect(progress.chunksTotal).toBeGreaterThan(0);
});
// Final call should show completion
const finalProgress = progressCalls[progressCalls.length - 1];
expect(finalProgress.bytesLoaded).toBe(fileSize);
expect(finalProgress.chunksCompleted).toBe(finalProgress.chunksTotal);
});
it('should calculate download speed accurately', async () => {
const fileSize = 128 * 1024;
const totalData = createTestAudioData(fileSize);
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206));
downloadManager = new DownloadManager({
onProgress: progressCallback
});
await downloadManager.downloadFile('https://example.com/audio.mp3');
const progressCalls = progressCallback.mock.calls.map(call => call[0] as DownloadProgress);
const lastProgress = progressCalls[progressCalls.length - 1];
expect(lastProgress.downloadSpeed).toBeGreaterThan(0);
expect(lastProgress.estimatedTimeRemaining).toBe(0); // Should be 0 when complete
});
});
describe('Error Handling', () => {
it('should handle network errors gracefully', async () => {
mockFetch.mockRejectedValueOnce(new Error('Network error'));
downloadManager = new DownloadManager({
onError: errorCallback
});
await expect(downloadManager.downloadFile('https://example.com/audio.mp3'))
.rejects.toThrow('Network error');
expect(errorCallback).toHaveBeenCalledWith(expect.any(Error));
});
it('should handle HTTP error responses', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found'
} as Response);
downloadManager = new DownloadManager({
onError: errorCallback
});
await expect(downloadManager.downloadFile('https://example.com/audio.mp3'))
.rejects.toThrow();
});
it('should retry failed chunk downloads', async () => {
const fileSize = 128 * 1024;
const totalData = createTestAudioData(fileSize);
// Range support check
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// First attempt fails
mockFetch.mockRejectedValueOnce(new Error('Temporary network error'));
// Second attempt succeeds
mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206));
downloadManager = new DownloadManager();
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(chunks).toHaveLength(1);
expect(mockFetch).toHaveBeenCalledTimes(3); // range check + failed attempt + successful retry
});
});
describe('Memory Management', () => {
it('should cleanup resources after download', async () => {
const fileSize = 64 * 1024;
const totalData = createTestAudioData(fileSize);
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
mockFetch.mockResolvedValueOnce(createMockResponse(totalData, 206));
downloadManager = new DownloadManager();
await downloadManager.downloadFile('https://example.com/audio.mp3');
// Call cleanup
downloadManager.cleanup();
// After cleanup, internal state should be reset
expect(downloadManager).toBeDefined(); // Manager still exists but cleaned up
});
});
describe('iOS Safari Optimizations', () => {
beforeEach(() => {
// Mock iOS Safari
Object.defineProperty(navigator, 'userAgent', {
writable: true,
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15'
});
});
it('should use smaller chunk sizes on iOS Safari', () => {
downloadManager = new DownloadManager();
// iOS should automatically get smaller chunk sizes and fewer concurrent downloads
expect(downloadManager).toBeDefined();
});
it('should limit concurrent downloads on iOS Safari', async () => {
const fileSize = 256 * 1024;
const totalData = createTestAudioData(fileSize);
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(0, 1024), 206));
// Mock multiple chunks
for (let i = 0; i < 4; i++) {
mockFetch.mockResolvedValueOnce(createMockResponse(totalData.slice(i * 64 * 1024, (i + 1) * 64 * 1024), 206));
}
downloadManager = new DownloadManager({
strategy: { maxConcurrentDownloads: 2 } // iOS limit
});
const chunks = await downloadManager.downloadFile('https://example.com/audio.mp3');
expect(chunks.length).toBeGreaterThan(0);
});
});
});