Files
iptvnator/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.lifecycle.spec.ts
T
4grayandClaude Fable 5.1 bc5a7fcbf9 fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive (#1803)
* fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive

On Linux native-view the support check runs `mpv --version` by bare name
and waits for the login shell PATH first. Since #1784 that lookup is
asynchronous with a 10 s budget; when it ran out, the check ran on the
inherited PATH and answered a plain `supported: false`. The settings store
took that as a verdict and persisted the default player over a saved
Embedded MPV selection. The main process probed again once the shell
answered, but nothing restored the setting.

`EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it
on a missing mpv while its probe has only seen the inherited PATH; the IPC
handler declares that state before probing and registers the re-probe
before the check, so a throwing check cannot leave it stuck. Every other
answer stays final.

Consumers no longer settle on an inconclusive answer: the settings store
keeps the saved player, and the command palette and the settings search
probe again on their next use instead of caching it for the session.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(playback): keep asking for Embedded MPV support while the answer is inconclusive

Keeping the saved player on an inconclusive answer left a mounted player
stuck on it: the session controller asked for support once, in its
constructor, and the session effect never starts while unsupported, so the
player did not recover after the login shell answered. The settings page
held its one answer the same way.

`watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final
or the surface is destroyed. The player controller and the settings page
facade load support through it, so playback starts by itself and the
Embedded MPV option appears without reopening the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): follow an inconclusive Embedded MPV answer to a final decision

The settings store checked a saved Embedded MPV selection once. After an
inconclusive answer it kept the selection and never looked again, so when
mpv turned out to be really missing the player stayed on Embedded MPV
instead of falling back to the default one.

The store now follows the answer with `watchEmbeddedMpvSupport()` until it
is final and only then decides. It acts on an answer only while Embedded
MPV is still the saved player, so a player picked meanwhile, also while
the first answer was pending, is never overwritten.

The watch backs off from 3 s to 30 s between rechecks, so a login shell
that never answers does not keep the app polling at the first rate, and it
no longer schedules a recheck after its answer handler stopped it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): keep the settings search following an inconclusive Embedded MPV answer

The settings page asks the search service for Embedded MPV support once,
when its search facade is created. After an inconclusive answer the service
only probed again on its next call, so with the page left open the
Embedded MPV rows stayed unsearchable after the login shell answered,
while the player option on the same page already updated.

The service now follows the answer with `watchEmbeddedMpvSupport()` until
it is final, which updates the open page and the command palette alike. A
call made while the answer is still inconclusive restarts the watch, so it
asks at once as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(settings): follow Embedded MPV support for search only while the settings page is open

The settings search service is provided in the root injector, so the
watch it started on its first use had no owner: with a login shell that
never answers it kept asking every 30 s until the app quit, long after
the settings page or the command palette that needed the answer was
closed. A failed recheck also ended the watch as if it were a final
answer, hiding the Embedded MPV rows for the rest of the session.

The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()`
is a single request again, for the command palette. The settings page
calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed.
Only a final answer is kept: after an inconclusive one or a failed
request the next use asks again, for the palette's player commands too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 10:54:19 +02:00

465 lines
16 KiB
TypeScript

import { TestBed } from '@angular/core/testing';
import {
EMBEDDED_MPV_SUPPORT_RECHECK_MS,
EmbeddedMpvEngine,
EmbeddedMpvSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
describe('EmbeddedMpvSessionController (lifecycle & support edges)', () => {
let electron: {
platform: string;
getEmbeddedMpvSupport: jest.Mock;
prepareEmbeddedMpv: jest.Mock;
createEmbeddedMpvSession: jest.Mock;
loadEmbeddedMpvPlayback: jest.Mock;
attachEmbeddedMpvFrameView: jest.Mock;
detachEmbeddedMpvFrameView: jest.Mock;
disposeEmbeddedMpvSession: jest.Mock;
setEmbeddedMpvBounds: jest.Mock;
onEmbeddedMpvSessionUpdate: jest.Mock;
};
const setBridge = (value: unknown) =>
Object.defineProperty(window, 'electron', {
configurable: true,
value,
});
beforeEach(() => {
// See `waitFor`: the startup chain is drained on a virtual clock so a
// loaded machine cannot change the outcome.
jest.useFakeTimers();
electron = {
platform: 'darwin',
getEmbeddedMpvSupport: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'darwin' }),
prepareEmbeddedMpv: jest
.fn()
.mockResolvedValue({ supported: true, platform: 'darwin' }),
createEmbeddedMpvSession: jest
.fn()
.mockResolvedValue(createSession({ id: 'mpv-1' })),
loadEmbeddedMpvPlayback: jest.fn().mockResolvedValue(undefined),
attachEmbeddedMpvFrameView: jest.fn().mockResolvedValue(true),
detachEmbeddedMpvFrameView: jest.fn(),
disposeEmbeddedMpvSession: jest.fn().mockResolvedValue(undefined),
setEmbeddedMpvBounds: jest.fn().mockResolvedValue(undefined),
onEmbeddedMpvSessionUpdate: jest.fn(() => jest.fn()),
};
setBridge(electron);
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class MockResizeObserver {
observe = jest.fn();
disconnect = jest.fn();
},
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: (handle: number) => window.clearTimeout(handle),
});
TestBed.configureTestingModule({
providers: [EmbeddedMpvSessionController],
});
});
afterEach(() => {
TestBed.resetTestingModule();
delete (window as unknown as { electron?: unknown }).electron;
jest.useRealTimers();
jest.restoreAllMocks();
});
it('reports unsupported outside the Electron desktop build', () => {
setBridge(undefined);
const controller = TestBed.inject(EmbeddedMpvSessionController);
expect(controller.support()).toEqual({
supported: false,
platform: 'unknown',
reason: 'Embedded MPV requires the Electron desktop build.',
});
});
it('maps a support-probe failure onto an unsupported result', async () => {
electron.getEmbeddedMpvSupport.mockRejectedValueOnce(
new Error('addon load failed')
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'support fallback to be set'
);
expect(controller.support()).toEqual({
supported: false,
platform: 'darwin',
reason: 'addon load failed',
});
});
describe('an inconclusive support answer', () => {
// A slow login shell: mpv was looked up before its PATH arrived.
const inconclusive = {
supported: false,
platform: 'linux',
reason: 'mpv executable missing',
inconclusive: true,
};
it('is asked for again, so a mounted player recovers by itself', async () => {
const settled = createSupport('native');
electron.getEmbeddedMpvSupport
.mockResolvedValueOnce(inconclusive)
.mockResolvedValue(settled);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
expect(controller.support()).toBe(inconclusive);
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS
);
expect(controller.support()).toBe(settled);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
// A final answer is kept.
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(2);
});
it('is no longer asked for once the player is gone', async () => {
electron.getEmbeddedMpvSupport.mockResolvedValue(inconclusive);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() !== null,
'the first support answer'
);
TestBed.resetTestingModule();
await jest.advanceTimersByTimeAsync(
EMBEDDED_MPV_SUPPORT_RECHECK_MS * 3
);
expect(electron.getEmbeddedMpvSupport).toHaveBeenCalledTimes(1);
});
});
it('preserves the constructor support probe after preparing a session', async () => {
const probedSupport = createSupport('native');
const preparedSupport = createSupport('frame-copy');
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(probedSupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(preparedSupport);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === probedSupport,
'constructor support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
try {
await waitFor(
() => electron.loadEmbeddedMpvPlayback.mock.calls.length > 0,
'playback load to start'
);
expect(controller.support()).toBe(probedSupport);
expect(electron.createEmbeddedMpvSession).toHaveBeenCalled();
expect(electron.loadEmbeddedMpvPlayback).toHaveBeenCalled();
} finally {
teardown();
}
});
it('sets an error session when prepare reports unsupported', async () => {
electron.prepareEmbeddedMpv.mockResolvedValueOnce({
supported: false,
platform: 'darwin',
reason: 'libmpv not found',
});
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.startSession(createHost(), createPlayback(), 0.5);
await waitFor(
() => controller.session()?.status === 'error',
'error session to be set'
);
expect(controller.session()?.error).toBe('libmpv not found');
expect(electron.createEmbeddedMpvSession).not.toHaveBeenCalled();
});
it('retry clears session state, stall flag, and bumps the retry token', () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.sessionId.set('mpv-1');
controller.session.set(createSession({ status: 'error' }));
controller.retry();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(controller.stalled()).toBe(false);
expect(controller.retryToken()).toBe(1);
});
it('flags a stalled session after 30s of loading and clears it on playback', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.session.set(createSession({ status: 'loading' }));
TestBed.tick();
jest.advanceTimersByTime(30_000);
expect(controller.stalled()).toBe(true);
controller.session.set(createSession({ status: 'playing' }));
TestBed.tick();
expect(controller.stalled()).toBe(false);
});
it('disposes a session whose creation resolves only after teardown', async () => {
let resolveCreate: ((session: EmbeddedMpvSession) => void) | undefined;
electron.createEmbeddedMpvSession.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveCreate = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => resolveCreate !== undefined,
'startup to reach createEmbeddedMpvSession'
);
teardown();
resolveCreate?.(createSession({ id: 'mpv-late' }));
await waitFor(
() => electron.disposeEmbeddedMpvSession.mock.calls.length > 0,
'late session to be disposed'
);
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-late'
);
expect(controller.sessionId()).toBeNull();
expect(electron.loadEmbeddedMpvPlayback).not.toHaveBeenCalled();
});
it('does not continue frame setup when teardown happens during playback load', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveLoad: (() => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.loadEmbeddedMpvPlayback.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveLoad = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === frameCopySupport,
'frame-copy support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => electron.loadEmbeddedMpvPlayback.mock.calls.length > 0,
'playback load to start'
);
teardown();
resolveLoad?.();
await flush();
expect(electron.attachEmbeddedMpvFrameView).not.toHaveBeenCalled();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-1'
);
expect(electron.detachEmbeddedMpvFrameView).toHaveBeenCalled();
});
it('does not schedule bounds after teardown during frame view attachment', async () => {
const frameCopySupport = createSupport('frame-copy');
let resolveAttach: ((attached: boolean) => void) | undefined;
electron.getEmbeddedMpvSupport.mockResolvedValueOnce(frameCopySupport);
electron.prepareEmbeddedMpv.mockResolvedValueOnce(frameCopySupport);
electron.attachEmbeddedMpvFrameView.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAttach = resolve;
})
);
const controller = TestBed.inject(EmbeddedMpvSessionController);
await waitFor(
() => controller.support() === frameCopySupport,
'frame-copy support probe to resolve'
);
const teardown = controller.startSession(
createHost(),
createPlayback(),
0.5
);
await waitFor(
() => electron.attachEmbeddedMpvFrameView.mock.calls.length > 0,
'frame view attachment to start'
);
teardown();
const requestAnimationFrame = jest.spyOn(
window,
'requestAnimationFrame'
);
resolveAttach?.(true);
await flush();
expect(requestAnimationFrame).not.toHaveBeenCalled();
expect(controller.session()).toBeNull();
expect(controller.sessionId()).toBeNull();
expect(electron.disposeEmbeddedMpvSession).toHaveBeenCalledWith(
'mpv-1'
);
expect(electron.detachEmbeddedMpvFrameView).toHaveBeenCalledTimes(1);
});
it('syncs bounds through a custom provider on triggerBoundsSync', async () => {
const controller = TestBed.inject(EmbeddedMpvSessionController);
controller.setBoundsProvider(() => ({
x: 1,
y: 2,
width: 300,
height: 200,
}));
controller.startSession(createHost(), createPlayback(), 0.5);
await waitFor(
() => controller.sessionId() === 'mpv-1',
'session to start'
);
electron.setEmbeddedMpvBounds.mockClear();
controller.triggerBoundsSync();
await waitFor(
() => electron.setEmbeddedMpvBounds.mock.calls.length > 0,
'bounds sync to run'
);
expect(electron.setEmbeddedMpvBounds).toHaveBeenCalledWith('mpv-1', {
x: 1,
y: 2,
width: 300,
height: 200,
});
});
});
function createHost(): HTMLElement {
return {
getBoundingClientRect: () => ({
left: 10,
top: 20,
width: 640,
height: 360,
}),
} as HTMLElement;
}
function createPlayback(): ResolvedPortalPlayback {
return {
streamUrl: 'https://example.com/movie.mp4',
title: 'Example Movie',
};
}
function createSupport(engine: EmbeddedMpvEngine) {
return { supported: true, platform: 'darwin', engine };
}
function createSession(
overrides: Partial<EmbeddedMpvSession> = {}
): EmbeddedMpvSession {
return {
id: 'mpv-1',
title: 'Example Movie',
streamUrl: 'https://example.com/movie.mp4',
status: 'playing',
positionSeconds: 10,
durationSeconds: 120,
volume: 0.7,
audioTracks: [],
selectedAudioTrackId: null,
subtitleTracks: [],
selectedSubtitleTrackId: null,
playbackSpeed: 1,
aspectOverride: 'no',
recording: { active: false },
startedAt: '2026-06-02T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:01.000Z',
...overrides,
};
}
async function drainRound(): Promise<void> {
await Promise.resolve();
await jest.advanceTimersByTimeAsync(1);
}
/**
* Settle the controller's async startup chain on the fake clock, bounded by
* drain rounds rather than wall-clock time — under parallel Jest workers a
* real-timer deadline expired before the chain settled and failed at random.
*
* One millisecond per round, never zero: `waitForStartupPaint` nests rAF
* inside rAF, and a zero-delay timer scheduled from inside a timer callback is
* clamped to the next millisecond, so a 0ms advance strands the inner hop.
*/
async function waitFor(
condition: () => boolean,
description: string
): Promise<void> {
for (let round = 0; round < 100; round += 1) {
if (condition()) {
return;
}
await drainRound();
}
throw new Error(`Timed out waiting for ${description}`);
}
/** Settle everything pending where there is no condition to poll for. */
async function flush(): Promise<void> {
for (let round = 0; round < 5; round += 1) {
await drainRound();
}
}