Files
iptvnator/libs/ui/playback/src/lib/html-video-player/html-video-player.component.shared-controls.sources.spec.ts
T
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00

345 lines
12 KiB
TypeScript

import { By } from '@angular/platform-browser';
import {
MockHls,
TEST_CHANNEL,
cleanupSharedControlsTests,
configureSharedControlsTests,
hlsInstances,
lifecycle,
mpegTsCreatePlayer,
mpegTsInstances,
mpegTsIsSupported,
observeBridgeSourceBinding,
readHtmlPlayerInternals,
renderSharedControls,
type SharedControlsFixture,
} from './html-video-player.component.shared-controls.spec-fixtures';
describe('HtmlVideoPlayerComponent shared controls sources', () => {
let HtmlVideoPlayerComponent: typeof import('./html-video-player.component').HtmlVideoPlayerComponent;
const fixtures: SharedControlsFixture[] = [];
beforeAll(async () => {
({ HtmlVideoPlayerComponent } =
await import('./html-video-player.component'));
});
beforeEach(async () => {
await configureSharedControlsTests(HtmlVideoPlayerComponent);
});
afterEach(() => {
cleanupSharedControlsTests(fixtures);
});
it('retains an HLS source created before bridge initialization', () => {
const { adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: false,
showCaptions: true,
}
);
expect(hlsInstances).toHaveLength(1);
expect(adapter.capabilities().audioTracks).toBe(true);
expect(adapter.state().audioTracks).toEqual([
{ id: 0, label: 'English', selected: true },
{ id: 1, label: 'Deutsch', selected: false },
]);
});
it('binds HLS controls after media attach and before source loading', () => {
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
lifecycle.length = 0;
component.playChannel(TEST_CHANNEL);
const attachIndex = lifecycle.indexOf('hls:attachMedia');
const bindIndex = lifecycle.indexOf(
`on:${MockHls.Events.AUDIO_TRACKS_UPDATED}`
);
const loadIndex = lifecycle.indexOf('hls:loadSource');
expect(attachIndex).toBeGreaterThanOrEqual(0);
expect(bindIndex).toBeGreaterThanOrEqual(0);
expect(loadIndex).toBeGreaterThanOrEqual(0);
expect(attachIndex).toBeLessThan(bindIndex);
expect(bindIndex).toBeLessThan(loadIndex);
});
it('does not write HLS provider data to development logs', () => {
const secret = 'hls-dev-log-secret';
const debug = jest
.spyOn(console, 'debug')
.mockImplementation(() => undefined);
jest.replaceProperty(process.env, 'NODE_ENV', 'development');
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
channel: {
...TEST_CHANNEL,
name: `Provider channel ${secret}`,
url: `https://user:${secret}@provider.example/live.m3u8?token=${secret}`,
epgParams: `&epg-token=${secret}`,
},
});
const serializedLogs = JSON.stringify(debug.mock.calls);
expect(serializedLogs).not.toContain(secret);
expect(serializedLogs).not.toContain('provider.example');
});
it.each([false, true])(
'passes authoritative isLive=%s to raw MPEG-TS playback',
(isLive) => {
mpegTsIsSupported.mockReturnValue(true);
renderSharedControls(HtmlVideoPlayerComponent, fixtures, {
channel: {
...TEST_CHANNEL,
url: 'https://example.test/raw.ts',
},
isLive,
});
expect(mpegTsCreatePlayer).toHaveBeenCalledWith({
type: 'mpegts',
isLive,
url: 'https://example.test/raw.ts',
});
}
);
it('emits structured HTTP evidence for mpegts.js failures', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: {
...TEST_CHANNEL,
url: 'https://example.test/missing.ts',
},
}
);
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
const secret = 'html-mpegts-secret';
mpegTsInstances[0].emit(
'error',
'NetworkError',
'HttpStatusCodeInvalid',
{
code: 404,
msg: `Not Found ${secret}`,
url: `https://provider.example/error?token=${secret}`,
}
);
expect(issues).toEqual([
expect.objectContaining({
code: 'network-error',
source: 'mpegts',
sourceUrl: 'https://example.test/missing.ts',
player: 'html5',
httpStatus: 404,
mpegTs: expect.objectContaining({
engineType: 'NetworkError',
engineDetails: 'HttpStatusCodeInvalid',
failure: 'http',
httpStatus: 404,
}),
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
});
it('owns one MPEG-TS source between media attachment and loading', () => {
mpegTsIsSupported.mockReturnValue(true);
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{ isLive: false }
);
const setSource = observeBridgeSourceBinding(component);
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/raw.ts',
});
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'mpegts' });
expect(internals.controlsSource).toEqual({ kind: 'mpegts' });
expect(internals.hls).toBeNull();
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(1);
expect(internals.mpegtsPlayer).toBe(mpegTsInstances[0]);
expect(mpegTsInstances[0].attachMediaElement).toHaveBeenCalledTimes(1);
expect(mpegTsInstances[0].load).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('mpegts:attachMedia')).toBeLessThan(
lifecycle.indexOf('bridge:mpegts')
);
expect(lifecycle.indexOf('bridge:mpegts')).toBeLessThan(
lifecycle.indexOf('mpegts:load')
);
});
it('owns one Shaka source for DASH (.mpd) streams', () => {
const { component } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const setSource = observeBridgeSourceBinding(component);
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/live.mpd',
drm: {
licenseType: 'clearkey',
supported: true,
clearKeys: { abc: 'def' },
},
});
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'shaka' })
);
expect(internals.controlsSource).toEqual(
expect.objectContaining({ kind: 'shaka' })
);
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(0);
});
it('owns one native source before loading native media', () => {
const { component, fixture } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures
);
const setSource = observeBridgeSourceBinding(component);
const video = fixture.debugElement.query(By.css('video'))
.nativeElement as HTMLVideoElement;
const load = jest
.spyOn(video, 'load')
.mockImplementation(() => lifecycle.push('native:load'));
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/movie.mp4',
});
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
expect(internals.controlsSource).toEqual({ kind: 'native' });
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(hlsInstances).toHaveLength(0);
expect(mpegTsInstances).toHaveLength(0);
expect(load).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('bridge:native')).toBeLessThan(
lifecycle.indexOf('native:load')
);
});
it('refreshes live and caption inputs through authoritative closures', () => {
const { fixture, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: true,
showCaptions: false,
}
);
const hls = hlsInstances[0];
expect(adapter.state().isLive).toBe(true);
expect(hls.subtitleDisplay).toBe(false);
fixture.componentRef.setInput('isLive', false);
fixture.componentRef.setInput('showCaptions', true);
fixture.detectChanges();
expect(adapter.state().isLive).toBe(false);
expect(hls.subtitleDisplay).toBe(true);
});
it('clears old HLS listeners and tracks before destroying the engine', () => {
const { component, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
isLive: false,
showCaptions: true,
}
);
const hls = hlsInstances[0];
const setSource = observeBridgeSourceBinding(component);
lifecycle.length = 0;
component.playChannel({
...TEST_CHANNEL,
url: 'https://example.test/movie.mp4',
});
const destroyIndex = lifecycle.indexOf('hls:destroy');
expect(destroyIndex).toBeGreaterThan(0);
expect(
lifecycle
.slice(0, destroyIndex)
.filter((event) => event.startsWith('off:'))
).toHaveLength(7);
const internals = readHtmlPlayerInternals(component);
expect(setSource).toHaveBeenCalledTimes(1);
expect(setSource).toHaveBeenCalledWith({ kind: 'native' });
expect(internals.controlsSource).toEqual({ kind: 'native' });
expect(internals.hls).toBeNull();
expect(internals.mpegtsPlayer).toBeNull();
expect(hlsInstances).toHaveLength(1);
expect(mpegTsInstances).toHaveLength(0);
expect(hls.off).toHaveBeenCalledTimes(7);
expect(adapter.state().audioTracks).toEqual([]);
expect(adapter.state().subtitleTracks).toEqual([]);
});
it('destroys the bridge before HLS and detaches the adapter once', () => {
const { component, adapter } = renderSharedControls(
HtmlVideoPlayerComponent,
fixtures,
{
channel: TEST_CHANNEL,
showCaptions: true,
}
);
const originalDetach = adapter.detach.bind(adapter);
const detach = jest.spyOn(adapter, 'detach').mockImplementation(() => {
lifecycle.push('adapter:detach');
originalDetach();
});
lifecycle.length = 0;
component.ngOnDestroy();
component.ngOnDestroy();
expect(detach).toHaveBeenCalledTimes(1);
expect(lifecycle.indexOf('adapter:detach')).toBeLessThan(
lifecycle.indexOf('hls:destroy')
);
});
});