mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(spf): HLS engine composition walkthrough + doc-driven cleanups (#1512)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
17d44a5d32
commit
0cfd3bb395
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChunkedStreamIterable } from '../chunked-stream-iterable';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (i < chunks.length) {
|
||||
controller.enqueue(chunks[i++]!);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function bytes(size: number, fill = 1): Uint8Array {
|
||||
return new Uint8Array(size).fill(fill);
|
||||
}
|
||||
|
||||
async function collect(iterable: AsyncIterable<Uint8Array>): Promise<Uint8Array[]> {
|
||||
const result: Uint8Array[] = [];
|
||||
for await (const chunk of iterable) {
|
||||
result.push(chunk);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function totalBytes(chunks: Uint8Array[]): number {
|
||||
return chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ChunkedStreamIterable', () => {
|
||||
it('exposes minChunkSize', () => {
|
||||
const stream = makeStream();
|
||||
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 1024 });
|
||||
expect(iterable.minChunkSize).toBe(1024);
|
||||
});
|
||||
|
||||
it('defaults minChunkSize to 128 KB', () => {
|
||||
const stream = makeStream();
|
||||
const iterable = new ChunkedStreamIterable(stream);
|
||||
expect(iterable.minChunkSize).toBe(2 ** 17);
|
||||
});
|
||||
|
||||
it('yields a single chunk when it meets minChunkSize exactly', async () => {
|
||||
const minChunkSize = 64;
|
||||
const stream = makeStream(bytes(64));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]!.length).toBe(64);
|
||||
});
|
||||
|
||||
it('yields a single chunk when it exceeds minChunkSize', async () => {
|
||||
const minChunkSize = 64;
|
||||
const stream = makeStream(bytes(100));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]!.length).toBe(100);
|
||||
});
|
||||
|
||||
it('accumulates small chunks until minChunkSize is met', async () => {
|
||||
const minChunkSize = 64;
|
||||
// 3 × 30-byte chunks — first two should accumulate, third triggers flush at 90 bytes
|
||||
const stream = makeStream(bytes(30, 1), bytes(30, 2), bytes(30, 3));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]!.length).toBe(90);
|
||||
});
|
||||
|
||||
it('flushes remaining bytes on stream end even if below minChunkSize', async () => {
|
||||
const minChunkSize = 128;
|
||||
const stream = makeStream(bytes(50));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(chunks[0]!.length).toBe(50);
|
||||
});
|
||||
|
||||
it('preserves all bytes across multiple yielded chunks', async () => {
|
||||
const minChunkSize = 50;
|
||||
// 3 × 40-byte chunks → first two accumulate to 80 (≥50, yield), third is remainder
|
||||
const stream = makeStream(bytes(40, 1), bytes(40, 2), bytes(40, 3));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(totalBytes(chunks)).toBe(120);
|
||||
});
|
||||
|
||||
it('concatenates chunk bytes correctly', async () => {
|
||||
const minChunkSize = 4;
|
||||
const a = new Uint8Array([1, 2]);
|
||||
const b = new Uint8Array([3, 4]);
|
||||
const stream = makeStream(a, b);
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(Array.from(chunks[0]!)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it('yields nothing for an empty stream', async () => {
|
||||
const stream = makeStream();
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize: 64 }));
|
||||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('propagates errors from the underlying stream', async () => {
|
||||
const errorStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.error(new Error('network failure'));
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collect(new ChunkedStreamIterable(errorStream, { minChunkSize: 64 }))).rejects.toThrow(
|
||||
'network failure'
|
||||
);
|
||||
});
|
||||
|
||||
it('releases the reader lock after normal completion', async () => {
|
||||
const stream = makeStream(bytes(10));
|
||||
const iterable = new ChunkedStreamIterable(stream, { minChunkSize: 64 });
|
||||
await collect(iterable);
|
||||
// If lock was not released, getReader() would throw
|
||||
expect(() => stream.getReader()).not.toThrow();
|
||||
});
|
||||
|
||||
it('releases the reader lock after an error', async () => {
|
||||
const errorStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.error(new Error('fail'));
|
||||
},
|
||||
});
|
||||
|
||||
const iterable = new ChunkedStreamIterable(errorStream, { minChunkSize: 64 });
|
||||
await expect(collect(iterable)).rejects.toThrow();
|
||||
// Lock should be released even though we errored
|
||||
expect(errorStream.locked).toBe(false);
|
||||
});
|
||||
|
||||
it('handles multiple large chunks correctly', async () => {
|
||||
const minChunkSize = 50;
|
||||
// Each chunk already meets minChunkSize → each yielded individually
|
||||
const stream = makeStream(bytes(60, 1), bytes(70, 2), bytes(80, 3));
|
||||
const chunks = await collect(new ChunkedStreamIterable(stream, { minChunkSize }));
|
||||
expect(chunks).toHaveLength(3);
|
||||
expect(chunks[0]!.length).toBe(60);
|
||||
expect(chunks[1]!.length).toBe(70);
|
||||
expect(chunks[2]!.length).toBe(80);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Resource, ResponseLike } from '../fetch';
|
||||
import { fetchResolvable, fetchResolvableStream, getResponseText } from '../fetch';
|
||||
|
||||
describe('fetchResolvable', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fetches from Resource URL', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('content'));
|
||||
|
||||
const addressable: Resource = {
|
||||
url: 'https://example.com/playlist.m3u8',
|
||||
};
|
||||
|
||||
const response = await fetchResolvable(addressable);
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(expect.any(Request));
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it('returns Response from fetch', async () => {
|
||||
const mockResponse = new Response('test content');
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await fetchResolvable({ url: 'https://example.com/test.m3u8' });
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
it('accepts Resource with just url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
|
||||
|
||||
const addressable: Resource = {
|
||||
url: 'https://example.com/playlist.m3u8',
|
||||
};
|
||||
|
||||
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts Resource with byteRange', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
|
||||
|
||||
const addressable: Resource = {
|
||||
url: 'https://example.com/segment.m4s',
|
||||
byteRange: { start: 1000, end: 1999 },
|
||||
};
|
||||
|
||||
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('handles zero-offset byte range', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(''));
|
||||
|
||||
const addressable: Resource = {
|
||||
url: 'https://example.com/init.mp4',
|
||||
byteRange: { start: 0, end: 999 },
|
||||
};
|
||||
|
||||
await expect(fetchResolvable(addressable)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchResolvableStream', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeBodyStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
||||
let i = 0;
|
||||
return new ReadableStream({
|
||||
pull(controller) {
|
||||
if (i < chunks.length) {
|
||||
controller.enqueue(chunks[i++]!);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(gen: AsyncGenerator<Uint8Array>): Promise<Uint8Array[]> {
|
||||
const result: Uint8Array[] = [];
|
||||
for await (const chunk of gen) result.push(chunk);
|
||||
return result;
|
||||
}
|
||||
|
||||
it('yields chunks from the response body', async () => {
|
||||
const data = new Uint8Array(256).fill(0xff);
|
||||
const body = makeBodyStream(data);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body));
|
||||
|
||||
const chunks = await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 128 }));
|
||||
const total = chunks.reduce((sum, c) => sum + c.length, 0);
|
||||
expect(total).toBe(256);
|
||||
});
|
||||
|
||||
it('passes the URL and byte-range header through to fetch', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
|
||||
|
||||
await collect(
|
||||
fetchResolvableStream(
|
||||
{ url: 'https://example.com/seg.m4s', byteRange: { start: 0, end: 99 } },
|
||||
{ minChunkSize: 64 }
|
||||
)
|
||||
);
|
||||
|
||||
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
|
||||
expect(req.headers.get('Range')).toBe('bytes=0-99');
|
||||
});
|
||||
|
||||
it('throws when the response has no body', async () => {
|
||||
const nullBodyResponse = new Response(null, { status: 204 });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(nullBodyResponse);
|
||||
|
||||
await expect(collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }))).rejects.toThrow(
|
||||
'Response has no body'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass minChunkSize as a fetch RequestInit option', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(makeBodyStream()));
|
||||
|
||||
await collect(fetchResolvableStream({ url: 'https://example.com/seg.m4s' }, { minChunkSize: 512 }));
|
||||
|
||||
// fetch should have been called with a Request, not an object with minChunkSize
|
||||
const req: Request = fetchSpy.mock.calls[0]![0] as Request;
|
||||
expect(req).toBeInstanceOf(Request);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResponseText', () => {
|
||||
it('extracts text from ResponseLike', async () => {
|
||||
const response: ResponseLike = {
|
||||
text: async () => '#EXTM3U\n#EXT-X-VERSION:7',
|
||||
};
|
||||
|
||||
const text = await getResponseText(response);
|
||||
expect(text).toBe('#EXTM3U\n#EXT-X-VERSION:7');
|
||||
});
|
||||
|
||||
it('returns promise from response.text()', async () => {
|
||||
const mockText = vi.fn().mockResolvedValue('playlist content');
|
||||
const response: ResponseLike = {
|
||||
text: mockText,
|
||||
};
|
||||
|
||||
const text = await getResponseText(response);
|
||||
|
||||
expect(mockText).toHaveBeenCalled();
|
||||
expect(text).toBe('playlist content');
|
||||
});
|
||||
|
||||
it('works with actual Response object', async () => {
|
||||
const response = new Response('#EXTM3U');
|
||||
const text = await getResponseText(response);
|
||||
expect(text).toBe('#EXTM3U');
|
||||
});
|
||||
|
||||
it('accepts minimal ResponseLike interface', () => {
|
||||
const response: ResponseLike = {
|
||||
text: async () => 'content',
|
||||
};
|
||||
|
||||
expect(response.text).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user