feat(spf): capability probing (#1676)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-17 11:22:49 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b89f1e944c
commit bce79ec424
19 changed files with 1100 additions and 132 deletions
@@ -2,11 +2,11 @@ import { defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, peek, type ReadonlySignal, type Signal, update } from '../../core/signals/primitives';
import { ConcurrentRunner, Task } from '../../core/tasks/task';
import { parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
import { NON_FMP4_CONTAINER_MIMES, parseMediaPlaylist } from '../../media/hls/parse-media-playlist';
import type { MaybeResolvedPresentation, PartiallyResolvedTrack, ResolvedTrack } from '../../media/types';
import { isResolvedPresentation, isResolvedTrack } from '../../media/types';
import type { GetCdnId } from '../../media/utils/cdn';
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { applyContainerMimeType, findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
import { failoverFetch } from '../primitives/failover-fetch';
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primitives/track-types';
@@ -132,9 +132,21 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// signal abort cancels in-flight body reads — so by the
// time we reach this point the presentation we resolved
// against is the live one.
update(state.presentation, (current) =>
isResolvedPresentation(current) ? updateTrackInPresentation(current, mediaTrack) : current
);
update(state.presentation, (current) => {
if (!isResolvedPresentation(current)) return current;
const patched = updateTrackInPresentation(current, mediaTrack);
// Container is uniform within a type (an ABR ladder shares
// its container), so a detected non-fMP4 rendition (TS,
// raw AAC) implies every rendition of *this* type matches —
// relabel them all from one resolved playlist instead of
// fetching each. Scoped to this track's own type: never cross
// audio↔video (mixed-container sources exist, e.g. muxed-TS
// video + raw-.aac audio), which also keeps per-type
// resolutions' writes disjoint (no race).
return NON_FMP4_CONTAINER_MIMES.has(mediaTrack.mimeType)
? applyContainerMimeType(patched, mediaTrack.type, mediaTrack.mimeType)
: patched;
});
},
{ id: track.id }
)
@@ -10,6 +10,7 @@ import type {
VideoSelectionSet,
VideoTrack,
} from '../../../media/types';
import { applyContainerMimeType } from '../../../media/utils/tracks';
import type { BandwidthState } from '../../../network/bandwidth-estimator';
import {
applyConstraints,
@@ -969,19 +970,126 @@ describe('excludeFailedCdns (failover constraint)', () => {
reactor.destroy();
});
it('keeps the prior pick when every CDN is in cooldown (nothing playable)', async () => {
it('clears the selection when every CDN is in cooldown, re-picking on recovery', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const state = makeState(undefined);
const reactor = switchVideoTrack.setup({ state });
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
// All CDNs cooled down → constraints prune everything → no playable set →
// the effect no-ops, leaving the last pick in place (deferred terminal-state
// modeling).
// All CDNs cooled down → constraints prune every track → no playable set →
// the selection clears (no pick) rather than lingering on an unreachable CDN.
state.failedCdns.set(['https://cdn-a.example.com', 'https://cdn-b.example.com']);
await flush();
expect(state.selectedVideoTrackId.get()).toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
// A CDN recovers → its tracks reappear → the candidate set refills and re-picks.
state.failedCdns.set(['https://cdn-b.example.com']);
await flush();
expect(state.selectedVideoTrackId.get()).toBe('1080p-a');
errorSpy.mockRestore();
reactor.destroy();
});
});
// ============================================================================
// excludeUnplayableTracks — the capability constraint (hard pre-pass)
// ============================================================================
describe('excludeUnplayableTracks (capability constraint)', () => {
const codecVideoTrack = (id: string, codec: string, bandwidth: number): PartiallyResolvedVideoTrack => ({
type: 'video',
codecs: [codec],
id,
url: `https://example.com/${id}.m3u8`,
bandwidth,
mimeType: 'video/mp4',
});
// Mixed HEVC + AVC ladder; the same bitrates on each codec.
const mixedCodecPresentation = () =>
createPresentation([
codecVideoTrack('720p-hevc', 'hvc1.1.6.L93.B0', 2_400_000),
codecVideoTrack('720p-avc', 'avc1.4d401f', 2_400_000),
codecVideoTrack('1080p-hevc', 'hvc1.1.6.L120.B0', 4_800_000),
codecVideoTrack('1080p-avc', 'avc1.640028', 4_800_000),
]);
// Rejects HEVC, accepts everything else.
const rejectsHevc = (track: { codecs?: string[] }) => !track.codecs?.some((c) => c.startsWith('hvc1'));
const makeState = () => ({
presentation: signal<MaybeResolvedPresentation | undefined>(mixedCodecPresentation()),
bandwidthState: signal<BandwidthState | undefined>(createBandwidthState(10_000_000)),
selectedVideoTrackId: signal<string | undefined>(undefined),
userVideoTrackSelection: signal<Partial<VideoTrack> | undefined>(undefined),
});
it('prunes undecodable renditions before ranking — picks the best playable codec', async () => {
const state = makeState();
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: rejectsHevc } });
await flush();
// HEVC pruned upstream; ranker picks the highest-bitrate AVC that fits.
expect(state.selectedVideoTrackId.get()).toBe('1080p-avc');
reactor.destroy();
});
it('passes everything through when no canPlayTrack probe is wired', async () => {
const state = makeState();
const reactor = switchVideoTrack.setup({ state });
await flush();
// No probe → HEVC survives; same-bitrate tie keeps manifest order, so the
// first 1080p (HEVC) wins.
expect(state.selectedVideoTrackId.get()).toBe('1080p-hevc');
reactor.destroy();
});
it('still excludes an unplayable track the user selected (hard constraint beats the soft filter)', async () => {
const state = makeState();
state.userVideoTrackSelection.set({ id: '1080p-hevc' });
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: rejectsHevc } });
await flush();
// The user's HEVC pick is pruned by the constraint before the user filter
// runs; the filter finds no match and falls through to the playable set.
expect(state.selectedVideoTrackId.get()).toBe('1080p-avc');
reactor.destroy();
});
it('makes no pick when the constraint prunes every rendition', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const state = makeState();
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack: () => false } });
await flush();
// Every codec rejected from a cold start → empty candidate set → nothing
// selected, and the empty-from-constraints case is flagged. The late
// createSourceBuffer check stays as the backstop.
expect(state.selectedVideoTrackId.get()).toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
reactor.destroy();
});
it('clears a prior pick when a later relabel prunes every rendition to empty', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const state = makeState();
// Accepts fMP4; rejects the non-fMP4 container MIME resolve-track relabels to.
const canPlayTrack = (track: { mimeType?: string }) => track.mimeType !== 'video/mp2t';
const reactor = switchVideoTrack.setup({ state, config: { canPlayTrack } });
await flush();
// Pick made while the tracks are still labeled video/mp4.
expect(state.selectedVideoTrackId.get()).toBe('1080p-hevc');
// resolve-track detects a TS container and relabels the whole video type;
// every rendition is now undecodable → candidate set empties → the now-stale
// pick clears (instead of lingering as an unplayable selection that stalls).
state.presentation.set(applyContainerMimeType(mixedCodecPresentation(), 'video', 'video/mp2t'));
await flush();
expect(state.selectedVideoTrackId.get()).toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
reactor.destroy();
});
});
@@ -5,10 +5,10 @@
* unload.
*
* Selection runs in two stages. First a **hard-constraints pre-pass**
* (`applyConstraints`) prunes the unplayable from the candidate set today the
* failed-CDN constraint (`excludeFailedCdns`, failover cooldown); capability
* probing will join it. Then a small ordered chain of rules (`applyRules`) picks
* among the survivors. Each constraint/rule reads the signals it needs at apply
* (`applyConstraints`) prunes the unplayable from the candidate set the
* failed-CDN constraint (`excludeFailedCdns`, failover cooldown) and the
* capability constraint (`excludeUnplayableTracks`, codec support). Then a small
* ordered chain of rules (`applyRules`) picks among the survivors. Each constraint/rule reads the signals it needs at apply
* time, so the effect subscribes to exactly what was consulted. The chain is
* three rules, most authoritative first:
*
@@ -41,18 +41,25 @@
* *scope* is the sticky-pick half of multi-CDN; the failed-CDN *constraint* is
* the failover half prune the cooled-down CDN, the scope falls to the next.)
*
* Deferred: capability probing as a second constraint; audio's preferred-
* language / default-track selection as standing soft-filter rules (previously
* the empty-slot picker, dropped in the move to the rule chain).
* When the pre-pass prunes a type's candidates to empty, the behavior leaves any
* prior pick in place and makes no new pick; the late `createSourceBuffer` check
* stays as the structural backstop for an unplayable rendition reaching the
* pipeline. Surfacing "nothing playable" as observable state is deferred until a
* consumer (error mapping) needs it.
*
* Deferred: audio's preferred-language / default-track selection as standing
* soft-filter rules (previously the empty-slot picker, dropped in the move to
* the rule chain).
*/
import { type AnySlotMap, defineBehavior } from '../../core/composition/create-composition';
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
import { computed, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
import { computed, peek, type ReadonlySignal, type Signal } from '../../core/signals/primitives';
import { DEFAULT_QUALITY_CONFIG, type QualityConfig, resolutionArea } from '../../media/abr/quality-selection';
import { matchesPartialTrack } from '../../media/primitives/select-tracks';
import {
type AudioTrack,
type CanPlayTrack,
isResolvedPresentation,
type MaybeResolvedPresentation,
type PartiallyResolvedAudioTrack,
@@ -96,6 +103,15 @@ export interface SwitchVideoTrackConfig {
initialBandwidth?: number;
/** Override CDN-id derivation (shared by the CDN scope + failover constraint). */
getCdnId?: GetCdnId;
/**
* Codec capability probe read by the `excludeUnplayableTracks` hard
* constraint drops renditions this environment can't decode before
* selection runs. Injected (rather than imported) so the DOM-free behavior
* never reaches a DOM API directly; the engine defaults it to the
* `MediaSource.isTypeSupported`-backed `canPlayTrack`. Absent no codec
* filtering (the constraint passes everything through).
*/
canPlayTrack?: CanPlayTrack;
}
/** Default initial-bandwidth value before bandwidth measurements arrive. */
@@ -222,7 +238,18 @@ export function applyConstraints<T, State, Context, Config>(
* audio candidates area-compare equal). Every resolved/partially-resolved video
* track carries them all; audio tracks omit the dimensions.
*/
type SwitchableTrack = { id: string; url: string; bandwidth?: number; width?: number; height?: number };
type SwitchableTrack = {
id: string;
url: string;
bandwidth?: number;
width?: number;
height?: number;
// Read by the capability constraint to probe codec support. Optional on the
// minimal shape; every resolved/partially-resolved video & audio candidate
// carries them, and an absent `mimeType` makes a track unprobeable (kept).
mimeType?: string;
codecs?: string[];
};
type SelectionKey = 'selectedVideoTrackId' | 'selectedAudioTrackId';
type UserSelectionKey = 'userVideoTrackSelection' | 'userAudioTrackSelection';
@@ -339,6 +366,17 @@ type CdnRuleConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwi
getCdnId?: GetCdnId;
};
/**
* Config the capability constraint reads: the base config plus an *optional*
* `canPlayTrack` codec probe. Optional an unwired probe means "no codec
* filtering" and the constraint passes everything through, so the base config
* (without it) stays assignable. The engine defaults it to the DOM-bound
* `canPlayTrack`.
*/
type CapabilityConstraintConfig<S extends SelectionKey, T extends SwitchableTrack> = TrackSwitchingConfig<S, T> & {
canPlayTrack?: CanPlayTrack;
};
type VideoTrackCandidate = PartiallyResolvedVideoTrack | VideoTrack;
type AudioTrackCandidate = PartiallyResolvedAudioTrack | AudioTrack;
@@ -373,8 +411,8 @@ function filterByUserSelection<S extends SelectionKey, U extends UserSelectionKe
*
* Passes everything through when there's no `failedCdns` signal/value. When it
* prunes *every* track (all CDNs cooled down), the empty result is preserved
* (per `applyConstraints`) "nothing playable," which today leaves the prior
* pick in place.
* (per `applyConstraints`) "nothing playable," which clears the selection (no
* pick); a later CDN recovery refills the candidate set and re-picks.
*/
function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
tracks: readonly T[],
@@ -387,6 +425,31 @@ function excludeFailedCdns<S extends SelectionKey, T extends SwitchableTrack>(
return tracks.filter((track) => !failedSet.has(getCdnId(track.url)));
}
/**
* Capability constraint a *hard* filter (constraints pre-pass), shared by
* video and audio. Removes renditions this environment can't decode, probed via
* the injected `canPlayTrack` (codec `MediaSource.isTypeSupported`). Moving
* the check here before selection means an unplayable variant (e.g. HEVC on
* a browser without HEVC) is pruned upstream and never picked, instead of
* surviving into the pipeline to fail late at `createSourceBuffer`. That late
* throw stays as a defensive structural guarantee; with this constraint it
* should rarely fire.
*
* Passes everything through when there's no `canPlayTrack` probe (a composition
* that didn't wire it, or DOM-free tests). When it prunes *every* track (no
* decodable rendition), the empty result is preserved (per `applyConstraints`)
* "nothing playable," so the behavior clears the selection (no pick) and the
* late `createSourceBuffer` check stays as the backstop.
*/
function excludeUnplayableTracks<S extends SelectionKey, T extends SwitchableTrack>(
tracks: readonly T[],
{ config }: SelectionRuleDeps<TrackSwitchingStateMap<S>, AnySlotMap, CapabilityConstraintConfig<S, T>>
): readonly T[] {
const canPlay = config.canPlayTrack;
if (!canPlay) return tracks;
return tracks.filter((track) => canPlay(track));
}
/**
* Active-CDN scope a soft filter, shared by video and audio. Narrows to the
* highest-priority CDN in `cdnPriority` (owned by `deriveCdnPriority`) that
@@ -532,14 +595,34 @@ function setupTrackSwitching<
effects: [
() => {
// Reactive read: subscribes the reaction to the candidate set, so a
// new presentation — or a future constraint pruning it — re-fires
// this and re-picks.
// new presentation — or a constraint pruning it — re-fires this and
// re-picks.
const tracks = candidateSet.get();
// No playable tracks: no tracks of this type, or (once constraints
// exist) everything pruned. Nothing to pick. Surfacing "nothing
// playable" as a distinct not-ready state is left to the constraints
// work; for now it's a silent no-op, leaving any prior pick in place.
if (!tracks.length) return;
// Empty candidate set — two shapes, told apart by whether the type
// has any tracks at all:
// - The type has no tracks (e.g. a video-only source's absent
// audio): legitimate, nothing to pick or clear.
// - The type HAS tracks but the hard-constraints pre-pass pruned
// every one (every rendition undecodable, or every CDN in
// failover cooldown): no playable rendition. Clear the selection
// so a pick made earlier — e.g. under the initial mp4 label,
// before resolve-track relabeled the type to a non-fMP4
// container — can't linger as a now-unplayable selection and
// silently stall the pipeline.
// The `console.error` is a placeholder until the planned error
// behaviors surface "nothing playable" as observable state.
if (!tracks.length) {
const presentation = peek(state.presentation);
const hasTracksOfType = isResolvedPresentation(presentation) && getTracks(presentation).length > 0;
if (hasTracksOfType) {
console.error(
`[track-switching] every ${selectionKey} candidate was filtered out by constraints; clearing selection`
);
state[selectionKey].set(undefined);
}
return;
}
// The whole deps object passes straight through to every rule in the
// variant-supplied chain (state + config from the behavior; context
@@ -602,7 +685,7 @@ export const switchVideoTrack = defineBehavior({
selectionKey: 'selectedVideoTrackId',
userSelectionKey: 'userVideoTrackSelection',
getTracks: (presentation) => getTracksByType(presentation, 'video') as readonly VideoTrackCandidate[],
constraints: [excludeFailedCdns],
constraints: [excludeFailedCdns, excludeUnplayableTracks],
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
},
}),
@@ -653,7 +736,7 @@ export const switchAudioTrack = defineBehavior({
selectionKey: 'selectedAudioTrackId',
userSelectionKey: 'userAudioTrackSelection',
getTracks: (presentation) => getTracksByType(presentation, 'audio') as readonly AudioTrackCandidate[],
constraints: [excludeFailedCdns],
constraints: [excludeFailedCdns, excludeUnplayableTracks],
rules: [filterByUserSelection, preferActiveCdn, rankByBandwidth],
},
}),
@@ -7,8 +7,9 @@ import {
import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composition/share-signals';
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
import { canPlayTrack } from '../../../media/dom/capabilities';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import type { AudioTrack, MaybeResolvedPresentation } from '../../../media/types';
import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation } from '../../../media/types';
import type { GetCdnId } from '../../../media/utils/cdn';
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
@@ -98,6 +99,14 @@ export type SimpleHlsAudioOnlyEngineSignals = {
export interface SimpleHlsAudioOnlyEngineConfig
extends ShareSignalsConfig<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
preferredAudioLanguage?: string;
/**
* Codec capability probe read by `track-switching`'s `excludeUnplayableTracks`
* constraint. Defaults to the `MediaSource.isTypeSupported`-backed
* `canPlayTrack`; override to force-exclude a codec. Mirrors the default
* engine without it, capability probing (and TS / raw-AAC detection) would
* be inert for audio-only playback.
*/
canPlayTrack?: CanPlayTrack;
resolveDuration?: PresentationDurationResolver;
parsePresentation?: ParsePresentation;
forwardBuffer?: Partial<ForwardBufferConfig>;
@@ -159,6 +168,7 @@ export function createHlsAudioOnlyEngine(
): Composition<SimpleHlsAudioOnlyEngineState, SimpleHlsAudioOnlyEngineContext> {
const finalConfig = {
...config,
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
};
@@ -8,6 +8,7 @@ import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composi
import type { QualityConfig } from '../../../media/abr/quality-selection';
import type { BackBufferConfig } from '../../../media/buffer/back-buffer';
import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer';
import { canPlayTrack } from '../../../media/dom/capabilities';
import { resolveVttSegment } from '../../../media/dom/text/resolve-vtt-segment';
import {
addSubtitlesTracksToMedia,
@@ -15,7 +16,7 @@ import {
removeAllSubtitlesTracksFromMedia,
} from '../../../media/dom/text/text-track-slots';
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
import type { AudioTrack, MaybeResolvedPresentation, VideoTrack } from '../../../media/types';
import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, VideoTrack } from '../../../media/types';
import type { GetCdnId } from '../../../media/utils/cdn';
import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection';
import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator';
@@ -135,6 +136,14 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
* collected. Default: `DEFAULT_INITIAL_BANDWIDTH` (5 Mbps).
*/
initialBandwidth?: number;
/**
* Codec capability probe injected into `track-switching`'s
* `excludeUnplayableTracks` constraint drops renditions the environment
* can't decode before selection. Defaults to the `MediaSource.isTypeSupported`
* -backed `canPlayTrack`; supply your own to override (e.g. force-exclude a
* codec).
*/
canPlayTrack?: CanPlayTrack;
preferredAudioLanguage?: string;
preferredSubtitleLanguage?: string;
includeForcedTracks?: boolean;
@@ -272,6 +281,7 @@ export function createSimpleHlsEngine(
): Composition<SimpleHlsEngineState, SimpleHlsEngineContext> {
const finalConfig = {
...config,
canPlayTrack: config.canPlayTrack ?? canPlayTrack,
resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment,
resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration,
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { snapshot } from '../../../../core/signals/primitives';
import type { Presentation } from '../../../../media/types';
import { createHlsAudioOnlyEngine } from '../engine-audio-only';
// Mock appendSegment to succeed without real MP4 data
@@ -7,15 +8,27 @@ vi.mock('../../../../media/dom/mse/append-segment', () => ({
appendSegment: vi.fn().mockResolvedValue(undefined),
}));
// Fallback for URLs a test's mock doesn't handle explicitly. Segment/init
// requests resolve with an empty body — the appendSegment mock makes the bytes
// inert — so the failover monitor isn't tripped by unmocked segment fetches (a
// single failed fetch trips that CDN into cooldown, which empties the candidate
// set). Genuinely unknown URLs still reject loudly.
function unmockedFetchFallback(url: string): Promise<Response> {
// Non-empty body: `fetchStream` throws "Response has no body" on a null body
// (empty Uint8Array), which would itself trip the monitor.
if (/\.(m4s|mp4|ts|aac)(\?|$)/.test(url)) return Promise.resolve(new Response(new Uint8Array([0])));
return Promise.reject(new Error(`Unmocked URL: ${url}`));
}
describe('createHlsAudioOnlyEngine', () => {
let originalFetch: typeof globalThis.fetch;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
// Tests assert at actor-presence and state-shape level, not at "init
// segment appended" level — so unmocked init/segment URLs in the manifests
// are intentional. The fetch loop's reject path leaks a console.error in
// each test; suppress only the expected patterns so genuine failures still
// surface.
// Tests assert at actor-presence and state-shape level, not at "init segment
// appended" level. Audio/video segment fetches resolve via
// `unmockedFetchFallback` (inert under the appendSegment mock); text-track
// segment fetches still reject and leak a console.error. Suppress only the
// expected patterns so genuine failures still surface.
const expectedErrorPatterns = [
/Unexpected error in segment loader.*Unmocked URL/s,
/Failed to load text-track segment/,
@@ -62,6 +75,51 @@ describe('createHlsAudioOnlyEngine', () => {
engine.destroy();
});
it('wires the default canPlayTrack — prunes an undecodable (raw-AAC) audio source, making no pick', async () => {
const flush = () => Promise.resolve().then(() => Promise.resolve());
// No canPlayTrack override → relies on the engine's default. A raw-AAC
// (audio/aac) rendition is asserted unplayable, so it should be pruned
// rather than selected. (If the default weren't wired, the constraint would
// pass through and select it.)
const engine = createHlsAudioOnlyEngine();
engine.state.presentation.set({
id: 'pres-aac',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [
{
id: 'a',
type: 'audio',
switchingSets: [
{
id: 'as',
type: 'audio',
tracks: [
{
type: 'audio',
id: 'aud-aac',
codecs: ['mp4a.40.2'],
url: 'https://example.com/aud.m3u8',
bandwidth: 128_000,
mimeType: 'audio/aac',
groupId: 'audio',
name: 'Default',
sampleRate: 48_000,
channels: 2,
},
],
},
],
},
],
} as Presentation);
await flush();
expect(engine.state.selectedAudioTrackId.get()).toBeUndefined();
engine.destroy();
});
it('does not seed bandwidthState (no ABR behavior subscribed at init)', () => {
const engine = createHlsAudioOnlyEngine();
@@ -101,7 +159,7 @@ http://example.com/audio-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -121,7 +179,9 @@ http://example.com/audio-seg1.m4s
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
},
{ timeout: 2000 }
);
@@ -163,7 +223,7 @@ http://example.com/audio-seg1.m4s
throw new Error('Audio-only variant fetched the video media playlist');
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -184,7 +244,9 @@ http://example.com/audio-seg1.m4s
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
expect(owners.mediaSource).toBeDefined();
expect((owners.mediaSource as MediaSource).readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
// Video-side slots absent — no composed behavior in this variant
// declares them. Behaviors that read these slots defensively
@@ -236,7 +298,7 @@ http://example.com/audio-seg1.m4s
throw new Error(`Audio-only variant fetched a non-audio playlist: ${url}`);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -8,15 +8,27 @@ vi.mock('../../../../media/dom/mse/append-segment', () => ({
appendSegment: vi.fn().mockResolvedValue(undefined),
}));
// Fallback for URLs a test's mock doesn't handle explicitly. Segment/init
// requests resolve with an empty body — the appendSegment mock makes the bytes
// inert — so the failover monitor isn't tripped by unmocked segment fetches (a
// single failed fetch trips that CDN into cooldown, which empties the candidate
// set). Genuinely unknown URLs still reject loudly.
function unmockedFetchFallback(url: string): Promise<Response> {
// Non-empty body: `fetchStream` throws "Response has no body" on a null body
// (empty Uint8Array), which would itself trip the monitor.
if (/\.(m4s|mp4|ts|aac)(\?|$)/.test(url)) return Promise.resolve(new Response(new Uint8Array([0])));
return Promise.reject(new Error(`Unmocked URL: ${url}`));
}
describe('createSimpleHlsEngine', () => {
let originalFetch: typeof globalThis.fetch;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
// Tests assert at actor-presence and state-shape level, not at "init
// segment appended" level — so unmocked init/segment URLs in the manifests
// are intentional. The fetch loop's reject path leaks a console.error in
// each test; suppress only the expected patterns so genuine failures still
// surface.
// Tests assert at actor-presence and state-shape level, not at "init segment
// appended" level. Audio/video segment fetches resolve via
// `unmockedFetchFallback` (inert under the appendSegment mock); text-track
// segment fetches still reject and leak a console.error. Suppress only the
// expected patterns so genuine failures still surface.
const expectedErrorPatterns = [
/Unexpected error in segment loader.*Unmocked URL/s,
/Failed to load text-track segment/,
@@ -285,6 +297,85 @@ describe('createSimpleHlsEngine', () => {
engine.destroy();
});
it('codec-filters renditions via the injected canPlayTrack before selection', async () => {
const flush = () => Promise.resolve().then(() => Promise.resolve());
// Reject HEVC; accept everything else.
const canPlayTrack = (track: { codecs?: string[] }) => !track.codecs?.some((c) => c.startsWith('hvc1'));
const engine = createSimpleHlsEngine({ canPlayTrack });
const videoTrack = (id: string, codec: string): PartiallyResolvedVideoTrack => ({
type: 'video',
id,
codecs: [codec],
url: `https://example.com/${id}.m3u8`,
bandwidth: 4_800_000,
mimeType: 'video/mp4',
});
engine.state.presentation.set({
id: 'pres-codec',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [
{
id: 'v',
type: 'video',
switchingSets: [
{
id: 'vs',
type: 'video',
tracks: [videoTrack('1080p-hevc', 'hvc1.1.6.L120.B0'), videoTrack('1080p-avc', 'avc1.640028')],
},
],
},
],
} as Presentation);
await flush();
// HEVC pruned upstream by the capability constraint; AVC selected.
expect(engine.state.selectedVideoTrackId.get()).toBe('1080p-avc');
engine.destroy();
});
it('makes no video pick when no rendition is decodable', async () => {
const flush = () => Promise.resolve().then(() => Promise.resolve());
const engine = createSimpleHlsEngine({ canPlayTrack: () => false });
engine.state.presentation.set({
id: 'pres-unsupported',
url: 'https://example.com/master.m3u8',
startTime: 0,
selectionSets: [
{
id: 'v',
type: 'video',
switchingSets: [
{
id: 'vs',
type: 'video',
tracks: [
{
type: 'video',
id: '1080p-hevc',
codecs: ['hvc1.1.6.L120.B0'],
url: 'https://example.com/1080p-hevc.m3u8',
bandwidth: 4_800_000,
mimeType: 'video/mp4',
} as PartiallyResolvedVideoTrack,
],
},
],
},
],
} as Presentation);
await flush();
expect(engine.state.selectedVideoTrackId.get()).toBeUndefined();
engine.destroy();
});
it('auto-fails-over when a CDN fetch fails (monitor trips, failedCdns set)', async () => {
const engine = createSimpleHlsEngine({ failover: { cooldownMs: 60_000 } });
@@ -455,7 +546,7 @@ http://example.com/segment1.m4s
}
// Fallback for unmocked URLs
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -523,7 +614,7 @@ http://example.com/audio-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -571,9 +662,11 @@ http://example.com/audio-seg1.m4s
// 4. MediaElement should be set
expect(owners.mediaElement).toBe(mediaElement);
// 5. MediaSource should be created and open
// 5. MediaSource should be created
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
// 6. Video buffer cluster should be created (actor presence implies
// `addSourceBuffer` ran; SourceBuffer itself is private to
@@ -661,7 +754,7 @@ http://example.com/audio-b-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -682,7 +775,9 @@ http://example.com/audio-b-seg1.m4s
expect(state.presentation?.id).toBeDefined();
expect(state.selectedVideoTrackId).toBeDefined();
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
expect(owners.videoBufferActor).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
},
@@ -715,7 +810,9 @@ http://example.com/audio-b-seg1.m4s
expect(state.selectedAudioTrackId).toBeDefined();
// Fresh MediaSource + buffer actors (different instances from A)
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
expect(owners.mediaSource).not.toBe(sourceAMediaSource);
expect(owners.videoBufferActor).not.toBe(sourceAVideoBufferActor);
expect(owners.audioBufferActor).not.toBe(sourceAAudioBufferActor);
@@ -752,7 +849,7 @@ http://example.com/video-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -779,7 +876,9 @@ http://example.com/video-seg1.m4s
// MediaSource should still be created
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
},
{ timeout: 2000 }
);
@@ -814,7 +913,7 @@ http://example.com/audio-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -841,7 +940,9 @@ http://example.com/audio-seg1.m4s
// MediaSource should still be created
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
},
{ timeout: 2000 }
);
@@ -876,7 +977,7 @@ http://example.com/video-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -931,7 +1032,7 @@ http://example.com/video-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1008,7 +1109,7 @@ http://example.com/audio-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1044,7 +1145,9 @@ http://example.com/audio-seg1.m4s
expect(state.selectedAudioTrackId).toBeDefined();
expect(owners.mediaSource).toBeDefined();
expect(owners.mediaSource?.readyState).toBe('open');
// readyState isn't asserted: with appendSegment mocked the stream completes
// instantly, so the MediaSource doesn't durably sit in 'open' (a created buffer
// actor implies addSourceBuffer ran, which requires an open MediaSource).
expect(owners.videoBufferActor).toBeDefined();
expect(owners.audioBufferActor).toBeDefined();
},
@@ -1086,7 +1189,7 @@ http://example.com/seg1.m4s
return Promise.resolve(new Response(new ArrayBuffer(100)));
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1159,7 +1262,7 @@ http://example.com/seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1201,10 +1304,15 @@ http://example.com/seg1.m4s
// The resolved track should be the selected one
expect(resolvedTracks?.[0]?.id).toBe(state.selectedVideoTrackId);
// Should fetch: 1 multivariant + 1 media playlist + init attempt
// (Only selected quality, not all 3 qualities; init.mp4 is attempted but
// rejected by the mock — segment is not attempted since init fails first)
expect(mockFetch).toHaveBeenCalledTimes(3);
// The non-selected qualities are never resolved — only the selected track's
// media playlist is fetched. Asserts the intent directly rather than a brittle
// total fetch count (which shifts with init/segment loading of the selected track).
const fetchedUrls = mockFetch.mock.calls.map((call: unknown[]) => {
const input = call[0] as RequestInfo | URL;
return typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url;
});
expect(fetchedUrls.some((u: string) => u.includes('video-720p.m3u8'))).toBe(false);
expect(fetchedUrls.some((u: string) => u.includes('video-1080p.m3u8'))).toBe(false);
engine.destroy();
});
@@ -1259,7 +1367,7 @@ http://example.com/text-es-seg1.vtt
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1351,7 +1459,7 @@ http://example.com/text-es-seg1.vtt
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1427,7 +1535,7 @@ http://example.com/text-fr-seg1.vtt
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1512,7 +1620,7 @@ http://example.com/text-es-seg1.vtt
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1604,7 +1712,7 @@ http://example.com/video-seg1.m4s
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1698,7 +1806,7 @@ http://example.com/text-es-seg1.vtt
);
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1797,7 +1905,7 @@ http://example.com/seg2.m4s
return Promise.resolve(new Response(new ArrayBuffer(1000)));
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;
@@ -1872,7 +1980,7 @@ http://example.com/audio-seg1.m4s
return Promise.resolve(new Response(new ArrayBuffer(1000)));
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
return unmockedFetchFallback(url);
});
globalThis.fetch = mockFetch;