mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(embedded-mpv): Linux frame-copy helper via headless EGL Port the frame-copy engine's native layer to Linux (PORTING.md items 1-4): - frame_helper_gl.h: platform GlContext abstraction. macOS keeps the CGL path (moved verbatim); Linux acquires an EGL display in order surfaceless-Mesa -> default display -> GBM render node, binds a 3.2 core desktop-GL context surfaceless (1x1 pbuffer fallback), and hands mpv eglGetProcAddress. The helper's own GL calls link against glvnd libOpenGL, so no display server is required. - frame_shm.h: portable frame_shm_now_ns() (CLOCK_MONOTONIC) shared by the helper and the reader addon, replacing the macOS-only clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); producer and consumer stay on the same clock. - embedded_mpv_frame_reader.c: real implementation now also on __linux__ (the code was already POSIX apart from the clock call). - binding.gyp: OS==linux executable branch for iptvnator_mpv_helper linking system libmpv (-lmpv) + EGL/OpenGL/gbm, with rpaths for $ORIGIN/lib and the build-time library dir. The in-process addon still does not link libmpv - the ban only binds in-process, the helper is out of process. - build-embedded-mpv.js: system-dev fallback on Linux (LIBMPV_INCLUDE_DIR or /usr/include) so a distro libmpv-dev install builds without staging a vendored runtime; a pre-set LINUX_NATIVE_LIBRARY_DIR now wins over the vendored lib dir. Verified on Ubuntu 25.04 / i7-1165G7 (Iris Xe): lavfi smoke per PORTING.md (idle->loading->playing snapshots at 4 Hz, aspect-fit generation bump g1 1280x720 -> g2 960x720 for a 4:3 source), reader probe 60 fps at 1080p60 with 0 torn reads, clean quit with no leaked processes or shm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(embedded-mpv): enable the frame-copy engine gates on Linux Flip the TypeScript side of the Linux port (PORTING.md item 5). A shared dependency-free predicate, isFrameCopyPlatformSupported() (linux any-arch, darwin arm64-only), now backs all four gates so they cannot drift: - main.ts: the persisted Settings toggle promotes to the env flag on Linux too (this runs before window creation and controls the sandbox relax). - EmbeddedMpvNativeService.isFrameCopyEngineActive/isFrameCopyAvailable. - EmbeddedMpvFrameCopyAdapter.isSupported. getSupport() ordering: the frame-copy branch moves above the Linux-only native-engine prerequisites - the X11/Xwayland display-server check and the system-mpv-on-PATH probe only bind the --wid native engine, while the frame-copy helper renders offscreen (headless EGL) and links libmpv itself. createSession() also skips resolving the native window handle for frame-copy sessions, which the adapter ignores anyway, so native-Wayland sessions no longer trip the window-handle assertion. Settings copy: the i18n frame-copy description now says macOS (Apple Silicon) and Linux in all 18 languages; stale macOS-only doc comments in the settings/support interfaces updated alongside. Tests: platform-gate matrix for the adapter (darwin arm64/x64, linux x64/arm64, win32) and service specs covering Linux activation under native Wayland, macOS arm64 staying active, macOS x64 staying native, and the skipped window handle for frame-copy sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(packaging): CI + package guards for the Linux frame-copy helper - build-and-make.yaml: install libegl-dev/libgl-dev/libgbm-dev on the Linux runner (the helper's EGL backend needs them now that the helper target builds on Linux), and verify the built helper exists and DOES link libmpv - the inverse of the addon's no-libmpv rule, which still holds and stays validated. - electron-after-pack.cjs: strip iptvnator_mpv_helper from packaged Linux apps. It links the build host's system libmpv, which end-user systems cannot be assumed to have; the support probe treats the missing helper as frame-copy-unavailable (dev-build-only engine until the bundled-runtime staging milestone). - frame_helper_gl.h: log the chosen EGL display tier to stderr (the adapter mirrors helper stderr), so bring-up problems on exotic setups are diagnosable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(embedded-mpv): document the Linux frame-copy port - architecture doc: frame-copy section covers Linux (EGL display tiers, build deps, package strip), Linux support matrix notes the frame-copy exception to the X11 + system-mpv requirements, Linux measured baseline. - RESULTS.md: Ubuntu 25.04 / i7-1165G7 (Iris Xe) measurement rows via the production helper + reader probe; viewport-size claim reproduced. - PORTING.md: Linux marked done with pointers to what changed; Windows remains the open port and its perf gate the open decision. - CLAUDE.md + tools/embedded-mpv/README.md: platform scope, Linux dev build requirements, system-headers fallback, helper strip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(embedded-mpv): commit the Linux frame-copy measurement probe linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the production helper, attaches the frame-reader addon to the announced shm generation, and reports new-frame fps, copy wall time, produce->copy age, torn reads and pixel spread. Usage documented in RESULTS.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address multi-agent review findings on the Linux port Confirmed findings (each verified by 3 adversarial reviewers): - CI would fail to link the helper: -lOpenGL needs the unversioned glvnd libOpenGL.so, shipped only by libopengl-dev, which neither the runner images nor the previous apt line provide. Added to the workflow and to every documented Linux build-dep list. - The new 'test -x' dist guard could never pass: webpack's dist asset copy drops file modes (helper arrives as 0644). The guard is now 'test -f'; electron-after-pack.cjs restores the execute bit on packaged helpers (also fixes packaged-macOS spawns); the support probe now requires X_OK, so a mode-stripped helper reads as frame-copy-unavailable and falls back to native instead of failing spawn with EACCES. - The Settings frame-copy toggle was unreachable in exactly the Linux states the port targets: the native-Wayland and missing-system-mpv unsupported payloads omitted frameCopyAvailable, and toggle visibility derives solely from it. Both returns now advertise availability. Also from review: - build-embedded-mpv.js keeps the old graceful-skip contract when the new system-dev fallback finds libmpv-dev but the GL/EGL/gbm dev stack is missing (previously such machines skipped; a hard electron-build failure was a regression). - createSession derives the window-handle skip from the dispatched addon instead of re-evaluating the engine gate, so the two cannot disagree. - The render thread logs the GL renderer string (surfaceless Mesa can silently pick llvmpipe on non-Mesa-primary systems; now diagnosable — verified 'Mesa Intel Iris Xe' on this machine). - Specs pin the new semantics: frameCopyAvailable advertised while native is unsupported (Wayland / no mpv), frame-copy supported without a system mpv, and the handle-skip test disposes its session through the owning adapter. - Docs: PORTING.md file map reflects the frame_helper_gl.h seam for the Windows porter; helper-strip removal correctly gated on milestone 4 (bundled libmpv), not milestone 3; RESULTS.md preamble notes the RAW->MONOTONIC clock change; stale '(macOS)' scope comments updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): address Greptile/Codex review comments - Sandbox gate requires a usable helper (Greptile P1, security): the main.ts env promotion now also probes for an executable iptvnator_mpv_helper before relaxing the window sandbox — a stale opt-in on packaged Linux (helper deliberately stripped) or after a cleaned native build no longer costs a sandboxless launch for an engine that cannot activate. Helper discovery (addon candidate paths + X_OK probe) moved into embedded-mpv-frame-copy-platform.util.ts, shared by main.ts and the service; the service keeps thin instance wrappers so tests can stub per scenario. New util spec pins the platform matrix, candidate resolution, and the execute-bit semantics. - Stale frame-copy artifacts on skipped builds (Codex P2): cleanOutput() now also removes iptvnator_mpv_helper and embedded_mpv_frame_reader.node, so a failed/skipped rebuild cannot leave a previous helper advertising frame-copy support against a runtime the build just declared unavailable. - Multiarch default lib dir (Greptile P1, partially refuted): -l resolution never depended on our -L (the compiler's built-in search paths include the Debian/Ubuntu multiarch dir — proven by the green CI run linking with a nonexistent -L dir), but the system-dev fallback now defaults to /usr/lib/<multiarch-triple> when present so the -L flag and the helper's baked rpath point somewhere real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(embedded-mpv): harden Linux frame-copy port --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1006 lines
36 KiB
TypeScript
1006 lines
36 KiB
TypeScript
import type {
|
|
EmbeddedMpvBounds,
|
|
EmbeddedMpvSessionStatus,
|
|
ResolvedPortalPlayback,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import path from 'path';
|
|
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
|
|
|
|
const mockSpawnSync = jest.fn();
|
|
|
|
jest.mock('child_process', () => ({
|
|
spawnSync: mockSpawnSync,
|
|
}));
|
|
|
|
const powerSaveBlockerMock = {
|
|
start: jest.fn<number, [string]>(),
|
|
stop: jest.fn<void, [number]>(),
|
|
isStarted: jest.fn<boolean, [number]>(),
|
|
};
|
|
const commandLineMock = {
|
|
getSwitchValue: jest.fn<string, [string]>(),
|
|
};
|
|
const appMock = {
|
|
isPackaged: true,
|
|
getAppPath: () => '/mock/app.asar',
|
|
commandLine: commandLineMock,
|
|
};
|
|
|
|
jest.mock('electron', () => ({
|
|
app: appMock,
|
|
powerSaveBlocker: powerSaveBlockerMock,
|
|
}));
|
|
|
|
const mainWindowSendMock = jest.fn();
|
|
const mainWindowWebContentsOnMock = jest.fn();
|
|
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
|
|
Buffer.alloc(8)
|
|
);
|
|
const mainWindowMock = {
|
|
isDestroyed: () => false,
|
|
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
|
|
webContents: { send: mainWindowSendMock, on: mainWindowWebContentsOnMock },
|
|
};
|
|
|
|
jest.mock('../app', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
get mainWindow() {
|
|
return mainWindowMock;
|
|
},
|
|
},
|
|
}));
|
|
|
|
interface MockSnapshot {
|
|
status: EmbeddedMpvSessionStatus;
|
|
positionSeconds: number;
|
|
durationSeconds: number | null;
|
|
volume: number;
|
|
streamUrl: string;
|
|
audioTracks?: never[];
|
|
selectedAudioTrackId?: number | null;
|
|
recording?: {
|
|
active: boolean;
|
|
targetPath?: string;
|
|
startedAt?: string;
|
|
error?: string;
|
|
};
|
|
error?: string;
|
|
}
|
|
|
|
interface MockAddon {
|
|
isSupported: jest.Mock<boolean, []>;
|
|
createSession: jest.Mock<
|
|
string,
|
|
[Buffer, EmbeddedMpvBounds, string?, number?]
|
|
>;
|
|
loadPlayback: jest.Mock<void, [string, ResolvedPortalPlayback]>;
|
|
setBounds: jest.Mock<void, [string, EmbeddedMpvBounds]>;
|
|
setPaused: jest.Mock<void, [string, boolean]>;
|
|
seek: jest.Mock<void, [string, number]>;
|
|
setVolume: jest.Mock<void, [string, number]>;
|
|
setAudioTrack: jest.Mock<void, [string, number]>;
|
|
startRecording: jest.Mock<void, [string, string]>;
|
|
stopRecording: jest.Mock<void, [string]>;
|
|
getSessionSnapshot: jest.Mock<MockSnapshot | null, [string]>;
|
|
disposeSession: jest.Mock<void, [string]>;
|
|
}
|
|
|
|
function createMockAddon(): MockAddon {
|
|
return {
|
|
isSupported: jest.fn().mockReturnValue(true),
|
|
createSession: jest.fn(),
|
|
loadPlayback: jest.fn(),
|
|
setBounds: jest.fn(),
|
|
setPaused: jest.fn(),
|
|
seek: jest.fn(),
|
|
setVolume: jest.fn(),
|
|
setAudioTrack: jest.fn(),
|
|
startRecording: jest.fn(),
|
|
stopRecording: jest.fn(),
|
|
getSessionSnapshot: jest.fn(),
|
|
disposeSession: jest.fn(),
|
|
};
|
|
}
|
|
|
|
const BOUNDS: EmbeddedMpvBounds = { x: 0, y: 0, width: 100, height: 100 };
|
|
|
|
describe('EmbeddedMpvNativeService power blocker', () => {
|
|
let EmbeddedMpvNativeService: typeof EmbeddedMpvNativeServiceType;
|
|
let service: EmbeddedMpvNativeServiceType;
|
|
let addon: MockAddon;
|
|
let nextBlockerId: number;
|
|
let originalPlatform: NodeJS.Platform;
|
|
let originalArch: string;
|
|
let originalDisplay: string | undefined;
|
|
let originalExperiment: string | undefined;
|
|
let originalOzonePlatformHint: string | undefined;
|
|
let originalWaylandDisplay: string | undefined;
|
|
let tempDirs: string[];
|
|
|
|
beforeEach(async () => {
|
|
jest.resetModules();
|
|
powerSaveBlockerMock.start.mockReset();
|
|
powerSaveBlockerMock.stop.mockReset();
|
|
powerSaveBlockerMock.isStarted.mockReset();
|
|
commandLineMock.getSwitchValue.mockReset();
|
|
commandLineMock.getSwitchValue.mockReturnValue('');
|
|
mockSpawnSync.mockReset();
|
|
mockSpawnSync.mockReturnValue({
|
|
status: 0,
|
|
});
|
|
mainWindowGetNativeWindowHandleMock.mockReset();
|
|
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
|
|
mainWindowSendMock.mockReset();
|
|
mainWindowWebContentsOnMock.mockReset();
|
|
appMock.isPackaged = true;
|
|
|
|
tempDirs = [];
|
|
nextBlockerId = 1;
|
|
powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++);
|
|
powerSaveBlockerMock.isStarted.mockReturnValue(true);
|
|
originalPlatform = process.platform;
|
|
originalArch = process.arch;
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'darwin',
|
|
});
|
|
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
|
originalDisplay = process.env.DISPLAY;
|
|
originalExperiment =
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
|
|
originalOzonePlatformHint = process.env.ELECTRON_OZONE_PLATFORM_HINT;
|
|
originalWaylandDisplay = process.env.WAYLAND_DISPLAY;
|
|
|
|
({ EmbeddedMpvNativeService } =
|
|
await import('./embedded-mpv-native.service'));
|
|
service = new EmbeddedMpvNativeService();
|
|
addon = createMockAddon();
|
|
// The addon is normally loaded via createRequire from a vendored .node
|
|
// file. For unit tests we inject a mock implementation directly.
|
|
(service as unknown as { addon: MockAddon }).addon = addon;
|
|
});
|
|
|
|
afterEach(() => {
|
|
service.shutdown();
|
|
jest.useRealTimers();
|
|
for (const tempDir of tempDirs) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
Object.defineProperty(process, 'platform', {
|
|
value: originalPlatform,
|
|
});
|
|
Object.defineProperty(process, 'arch', { value: originalArch });
|
|
restoreEnv('DISPLAY', originalDisplay);
|
|
restoreEnv(
|
|
'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT',
|
|
originalExperiment
|
|
);
|
|
restoreEnv('ELECTRON_OZONE_PLATFORM_HINT', originalOzonePlatformHint);
|
|
restoreEnv('WAYLAND_DISPLAY', originalWaylandDisplay);
|
|
});
|
|
|
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
if (value === undefined) {
|
|
delete process.env[key];
|
|
return;
|
|
}
|
|
|
|
process.env[key] = value;
|
|
}
|
|
|
|
function createTempDir(): string {
|
|
const tempDir = mkdtempSync(
|
|
path.join(tmpdir(), 'iptvnator-recording-')
|
|
);
|
|
tempDirs.push(tempDir);
|
|
return tempDir;
|
|
}
|
|
|
|
function startSession(sessionId: string, snapshot: MockSnapshot): void {
|
|
addon.createSession.mockReturnValueOnce(sessionId);
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot);
|
|
service.createSession(BOUNDS, '', 1);
|
|
}
|
|
|
|
function snapshot(
|
|
status: EmbeddedMpvSessionStatus,
|
|
overrides: Partial<MockSnapshot> = {}
|
|
): MockSnapshot {
|
|
return {
|
|
status,
|
|
positionSeconds: 0,
|
|
durationSeconds: null,
|
|
volume: 1,
|
|
streamUrl: 'mock://stream',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it('falls back to the native engine when frame-copy is requested without a helper binary', () => {
|
|
// A stale opt-in (cleaned native build) must not brick embedded MPV:
|
|
// no helper on disk => the engine env flag is ignored, native keeps
|
|
// working, and support does not advertise frame-copy.
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
jest.spyOn(
|
|
service as unknown as {
|
|
resolveFrameCopyHelperPath: () => string | null;
|
|
},
|
|
'resolveFrameCopyHelperPath'
|
|
).mockReturnValue(null);
|
|
try {
|
|
expect(service.getActiveEngine()).toBe('native');
|
|
const support = service.getSupport();
|
|
expect(support.engine).not.toBe('frame-copy');
|
|
startSession('s-fallback', snapshot('loading'));
|
|
expect(addon.createSession).toHaveBeenCalled();
|
|
} finally {
|
|
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
|
}
|
|
});
|
|
|
|
it('requires the base embedded-MPV opt-in for unpackaged runs', () => {
|
|
appMock.isPackaged = false;
|
|
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT;
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({ supported: false })
|
|
);
|
|
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = '1';
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({ supported: true })
|
|
);
|
|
});
|
|
|
|
(process.platform === 'win32' ? it.skip : it)(
|
|
'falls back to the native engine when the frame-copy helper is not executable',
|
|
() => {
|
|
const tempDir = createTempDir();
|
|
const releaseDir = path.join(
|
|
tempDir,
|
|
'apps',
|
|
'electron-backend',
|
|
'native',
|
|
'build',
|
|
'Release'
|
|
);
|
|
const helperPath = path.join(
|
|
releaseDir,
|
|
'iptvnator_mpv_helper'
|
|
);
|
|
mkdirSync(releaseDir, { recursive: true });
|
|
writeFileSync(helperPath, '#!/bin/sh\n');
|
|
chmodSync(helperPath, 0o644);
|
|
writeFileSync(
|
|
path.join(releaseDir, 'embedded_mpv_frame_reader.node'),
|
|
'reader'
|
|
);
|
|
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir);
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
|
|
try {
|
|
expect(service.getActiveEngine()).toBe('native');
|
|
expect(service.isFrameCopyAvailable()).toBe(false);
|
|
} finally {
|
|
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
|
cwdSpy.mockRestore();
|
|
}
|
|
}
|
|
);
|
|
|
|
describe('frame-copy platform gate', () => {
|
|
const originalArch = process.arch;
|
|
|
|
function mockHelperPresent(): void {
|
|
jest.spyOn(
|
|
service as unknown as {
|
|
resolveFrameCopyHelperPath: () => string | null;
|
|
},
|
|
'resolveFrameCopyHelperPath'
|
|
).mockReturnValue('/native/iptvnator_mpv_helper');
|
|
}
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'arch', { value: originalArch });
|
|
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
|
|
});
|
|
|
|
it('activates the frame-copy engine on Linux, even under native Wayland', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
// Native Wayland session (no X11 ozone): blocks the --wid native
|
|
// engine, but the frame-copy helper renders offscreen into shm
|
|
// and must stay available.
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
mockHelperPresent();
|
|
|
|
expect(service.getActiveEngine()).toBe('frame-copy');
|
|
expect(service.isFrameCopyAvailable()).toBe(true);
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
engine: 'frame-copy',
|
|
frameCopyAvailable: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it('advertises frame-copy availability while native Wayland blocks the native engine', () => {
|
|
// Pre-opt-in discoverability: without frameCopyAvailable on the
|
|
// unsupported payload the Settings toggle never appears in
|
|
// exactly the states the frame-copy engine exists to fix.
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
mockHelperPresent();
|
|
|
|
const support = service.getSupport();
|
|
expect(support.supported).toBe(false);
|
|
expect(support.reason).toContain('Native Wayland embedding');
|
|
expect(support.frameCopyAvailable).toBe(true);
|
|
});
|
|
|
|
it('advertises frame-copy availability when the system mpv executable is missing', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
process.env.DISPLAY = ':0';
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
mockSpawnSync.mockReturnValue({ status: 1 });
|
|
mockHelperPresent();
|
|
|
|
const support = service.getSupport();
|
|
expect(support.supported).toBe(false);
|
|
expect(support.frameCopyAvailable).toBe(true);
|
|
});
|
|
|
|
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
|
|
// The helper links libmpv itself; the mpv-on-PATH probe only
|
|
// binds the native --wid engine.
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
process.env.DISPLAY = ':0';
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
mockSpawnSync.mockReturnValue({ status: 1 });
|
|
mockHelperPresent();
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
engine: 'frame-copy',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('activates the frame-copy engine on macOS arm64', () => {
|
|
Object.defineProperty(process, 'arch', { value: 'arm64' });
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
mockHelperPresent();
|
|
|
|
expect(service.getActiveEngine()).toBe('frame-copy');
|
|
expect(service.isFrameCopyAvailable()).toBe(true);
|
|
});
|
|
|
|
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
|
|
Object.defineProperty(process, 'arch', { value: 'x64' });
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
mockHelperPresent();
|
|
|
|
expect(service.getActiveEngine()).toBe('native');
|
|
expect(service.isFrameCopyAvailable()).toBe(false);
|
|
});
|
|
|
|
it('skips the native window handle when creating a frame-copy session', () => {
|
|
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
|
mockHelperPresent();
|
|
const frameCopyAddon = createMockAddon();
|
|
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
|
|
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
|
|
snapshot('loading')
|
|
);
|
|
(
|
|
service as unknown as { frameCopyAdapter: MockAddon }
|
|
).frameCopyAdapter = frameCopyAddon;
|
|
|
|
service.createSession(BOUNDS, '', 1);
|
|
|
|
expect(mainWindowGetNativeWindowHandleMock).not.toHaveBeenCalled();
|
|
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
|
|
Buffer.alloc(0),
|
|
BOUNDS,
|
|
'',
|
|
1
|
|
);
|
|
|
|
// Dispose while the frame-copy env is still set so teardown
|
|
// dispatches to the adapter that owns the session, not the
|
|
// native addon the outer afterEach shutdown would pick.
|
|
service.disposeSession('s-fc');
|
|
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
|
|
's-fc'
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not acquire a blocker for a loading session', () => {
|
|
startSession('s1', snapshot('loading'));
|
|
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disposes sessions when the renderer reloads or crashes', () => {
|
|
// Angular teardown never runs on a renderer crash or hard reload, so
|
|
// the main process must reap sessions itself — otherwise native mpv
|
|
// handles / frame-copy helper processes leak until app shutdown.
|
|
startSession('s1', snapshot('playing'));
|
|
addon.getSessionSnapshot.mockReturnValue(snapshot('playing'));
|
|
|
|
const handlers = new Map<string, (...args: unknown[]) => void>(
|
|
mainWindowWebContentsOnMock.mock.calls.map(
|
|
([event, handler]: [string, (...args: unknown[]) => void]) => [
|
|
event,
|
|
handler,
|
|
]
|
|
)
|
|
);
|
|
expect([...handlers.keys()]).toEqual(
|
|
expect.arrayContaining(['render-process-gone', 'did-navigate'])
|
|
);
|
|
|
|
const consoleWarnSpy = jest
|
|
.spyOn(console, 'warn')
|
|
.mockImplementation();
|
|
handlers.get('did-navigate')?.();
|
|
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
|
|
|
|
// A crash after everything is already disposed must be a no-op.
|
|
addon.disposeSession.mockClear();
|
|
handlers.get('render-process-gone')?.(undefined, {
|
|
reason: 'crashed',
|
|
});
|
|
expect(addon.disposeSession).not.toHaveBeenCalled();
|
|
consoleWarnSpy.mockRestore();
|
|
});
|
|
|
|
it('keeps the polling timer alive when refreshing a session throws', () => {
|
|
jest.useFakeTimers();
|
|
const consoleErrorSpy = jest
|
|
.spyOn(console, 'error')
|
|
.mockImplementation();
|
|
|
|
try {
|
|
startSession('s1', snapshot('playing'));
|
|
startSession('s2', snapshot('playing'));
|
|
|
|
// s1 keeps failing while s2 stays healthy: the healthy session
|
|
// must not reset the log suppression for the failing one.
|
|
addon.getSessionSnapshot.mockImplementation((sessionId: string) => {
|
|
if (sessionId === 's1') {
|
|
throw new Error('addon crashed');
|
|
}
|
|
return snapshot('playing');
|
|
});
|
|
|
|
// Three poll ticks: nothing may escape the interval callback,
|
|
// and the failure is logged once instead of at poll rate.
|
|
expect(() => jest.advanceTimersByTime(1500)).not.toThrow();
|
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
|
|
// Once the addon recovers, session updates flow again.
|
|
addon.getSessionSnapshot.mockImplementation(() =>
|
|
snapshot('playing', { positionSeconds: 42 })
|
|
);
|
|
mainWindowSendMock.mockClear();
|
|
jest.advanceTimersByTime(500);
|
|
expect(mainWindowSendMock).toHaveBeenCalled();
|
|
|
|
// A recovered session that fails again logs once more (one line
|
|
// per session per failure streak, not one per service lifetime).
|
|
addon.getSessionSnapshot.mockImplementation(() => {
|
|
throw new Error('addon crashed again');
|
|
});
|
|
jest.advanceTimersByTime(1000);
|
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(3);
|
|
} finally {
|
|
consoleErrorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('suppresses session updates while the snapshot content is unchanged', () => {
|
|
jest.useFakeTimers();
|
|
startSession('s1', snapshot('playing'));
|
|
|
|
// Identical snapshot on every poll tick: only the refresh timestamp
|
|
// differs, which must not count as a change.
|
|
addon.getSessionSnapshot.mockImplementation(() => snapshot('playing'));
|
|
mainWindowSendMock.mockClear();
|
|
jest.advanceTimersByTime(2000);
|
|
expect(mainWindowSendMock).not.toHaveBeenCalled();
|
|
|
|
addon.getSessionSnapshot.mockImplementation(() =>
|
|
snapshot('playing', { positionSeconds: 1 })
|
|
);
|
|
jest.advanceTimersByTime(500);
|
|
expect(mainWindowSendMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('acquires a single prevent-display-sleep blocker once a session is playing', () => {
|
|
startSession('s1', snapshot('loading'));
|
|
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing'));
|
|
service.setPaused('s1', false);
|
|
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledWith(
|
|
'prevent-display-sleep'
|
|
);
|
|
|
|
// Subsequent refreshes while still playing must not start a second blocker.
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('playing'));
|
|
service.setPaused('s1', false);
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('releases the blocker when the session transitions to paused', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
|
|
service.setPaused('s1', true);
|
|
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('releases the blocker when MPV reports playback ended', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('ended'));
|
|
service.setPaused('s1', false);
|
|
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledWith(1);
|
|
});
|
|
|
|
it('keeps the blocker held while any other session is still playing', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
startSession('s2', snapshot('playing'));
|
|
|
|
// Only one blocker for both sessions.
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
|
|
// s1 pauses — s2 still playing, so do not release.
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
|
|
service.setPaused('s1', true);
|
|
expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled();
|
|
|
|
// s2 pauses — now there is nothing playing.
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
|
|
service.setPaused('s2', true);
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('releases the blocker when the playing session is disposed', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
|
|
service.disposeSession('s1');
|
|
|
|
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('releases the blocker on shutdown', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
expect(powerSaveBlockerMock.start).toHaveBeenCalledTimes(1);
|
|
|
|
service.shutdown();
|
|
|
|
expect(powerSaveBlockerMock.stop).toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips powerSaveBlocker.stop if the assertion was already cleared externally', () => {
|
|
startSession('s1', snapshot('playing'));
|
|
powerSaveBlockerMock.isStarted.mockReturnValue(false);
|
|
|
|
addon.getSessionSnapshot.mockReturnValueOnce(snapshot('paused'));
|
|
service.setPaused('s1', true);
|
|
|
|
expect(powerSaveBlockerMock.stop).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports recording support when the addon exposes recording methods', () => {
|
|
expect(service.getSupport().capabilities?.recording).toBe(true);
|
|
});
|
|
|
|
it.each<NodeJS.Platform>(['darwin', 'win32', 'linux'])(
|
|
'reports embedded MPV support on %s when the addon is already loaded',
|
|
(platform) => {
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
Object.defineProperty(process, 'platform', {
|
|
value: platform,
|
|
});
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
platform,
|
|
})
|
|
);
|
|
}
|
|
);
|
|
|
|
it('reports Linux Wayland as unsupported unless Electron is using X11/Xwayland', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
|
|
const support = service.getSupport();
|
|
|
|
expect(support.supported).toBe(false);
|
|
expect(support.reason).toContain('Native Wayland embedding');
|
|
});
|
|
|
|
it('does not treat the ozone platform hint env as proof that Electron is using X11', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
|
|
|
|
expect(service.getSupport().supported).toBe(false);
|
|
});
|
|
|
|
it('reports Linux Wayland as supported when X11 ozone is requested', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
commandLineMock.getSwitchValue.mockReturnValue('x11');
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
platform: 'linux',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('reports Linux as unsupported when the mpv executable is missing', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
mockSpawnSync.mockReturnValueOnce({
|
|
status: null,
|
|
error: Object.assign(new Error('not found'), { code: 'ENOENT' }),
|
|
});
|
|
|
|
const support = service.getSupport();
|
|
|
|
expect(support.supported).toBe(false);
|
|
expect(support.reason).toContain('requires the mpv executable on PATH');
|
|
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
|
stdio: 'ignore',
|
|
timeout: 3000,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['Flatpak', 'FLATPAK_ID', 'com.fourgray.iptvnator'],
|
|
['Snap', 'SNAP', '/snap/iptvnator/1'],
|
|
])(
|
|
'explains that %s sandboxes cannot use a system mpv instead of asking to install it',
|
|
(_label, envKey, envValue) => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
const originalValue = process.env[envKey];
|
|
process.env[envKey] = envValue;
|
|
mockSpawnSync.mockReturnValueOnce({
|
|
status: null,
|
|
error: Object.assign(new Error('not found'), {
|
|
code: 'ENOENT',
|
|
}),
|
|
});
|
|
|
|
try {
|
|
const support = service.getSupport();
|
|
|
|
expect(support.supported).toBe(false);
|
|
expect(support.reason).toContain('sandboxed Flatpak/Snap');
|
|
expect(support.reason).not.toContain('Install the mpv package');
|
|
} finally {
|
|
restoreEnv(envKey, originalValue);
|
|
}
|
|
}
|
|
);
|
|
|
|
it('caches the Linux mpv executable probe result', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
|
|
service.getSupport();
|
|
service.getSupport();
|
|
|
|
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
|
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
|
stdio: 'ignore',
|
|
timeout: 3000,
|
|
});
|
|
});
|
|
|
|
it('rejects Electron native Wayland placeholder handles before calling the addon', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
mainWindowGetNativeWindowHandleMock.mockReturnValueOnce(
|
|
Buffer.from([1, 0, 0, 0])
|
|
);
|
|
|
|
expect(() => service.createSession(BOUNDS, '', 1)).toThrow(
|
|
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland.'
|
|
);
|
|
expect(addon.createSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects 64-bit Electron native Wayland placeholder handles before calling the addon', () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
process.env.DISPLAY = ':0';
|
|
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
|
mainWindowGetNativeWindowHandleMock.mockReturnValueOnce(
|
|
Buffer.alloc(8)
|
|
);
|
|
|
|
expect(() => service.createSession(BOUNDS, '', 1)).toThrow(
|
|
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland.'
|
|
);
|
|
expect(addon.createSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
platform: 'darwin' as NodeJS.Platform,
|
|
runtimeFile: path.join('lib', 'libmpv.2.dylib'),
|
|
},
|
|
{
|
|
platform: 'win32' as NodeJS.Platform,
|
|
runtimeFile: path.join('lib', 'mpv-2.dll'),
|
|
},
|
|
{
|
|
platform: 'win32' as NodeJS.Platform,
|
|
runtimeFile: path.join('lib', 'libmpv-2.dll'),
|
|
},
|
|
])(
|
|
'loads the addon after validating the $platform runtime file exists',
|
|
({ platform, runtimeFile }) => {
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
Object.defineProperty(process, 'platform', {
|
|
value: platform,
|
|
});
|
|
const nativeDir = createTempDir();
|
|
const addonPath = path.join(nativeDir, 'embedded_mpv.node');
|
|
const runtimePath = path.join(nativeDir, runtimeFile);
|
|
mkdirSync(path.dirname(runtimePath), { recursive: true });
|
|
writeFileSync(addonPath, '');
|
|
writeFileSync(runtimePath, '');
|
|
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
|
|
|
Object.assign(service as unknown as Record<string, unknown>, {
|
|
addon: null,
|
|
addonLoadError: null,
|
|
loadAddonModule,
|
|
getAddonCandidatePaths: () => [addonPath],
|
|
});
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
platform,
|
|
})
|
|
);
|
|
expect(loadAddonModule).toHaveBeenCalledWith(addonPath);
|
|
}
|
|
);
|
|
|
|
it('loads the Linux addon without bundled libmpv runtime files', () => {
|
|
delete process.env.WAYLAND_DISPLAY;
|
|
Object.defineProperty(process, 'platform', {
|
|
value: 'linux',
|
|
});
|
|
const nativeDir = createTempDir();
|
|
const addonPath = path.join(nativeDir, 'embedded_mpv.node');
|
|
writeFileSync(addonPath, '');
|
|
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
|
|
|
Object.assign(service as unknown as Record<string, unknown>, {
|
|
addon: null,
|
|
addonLoadError: null,
|
|
loadAddonModule,
|
|
getAddonCandidatePaths: () => [addonPath],
|
|
});
|
|
|
|
expect(service.getSupport()).toEqual(
|
|
expect.objectContaining({
|
|
supported: true,
|
|
platform: 'linux',
|
|
})
|
|
);
|
|
expect(loadAddonModule).toHaveBeenCalledWith(addonPath);
|
|
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
|
stdio: 'ignore',
|
|
timeout: 3000,
|
|
});
|
|
});
|
|
|
|
it('loads the addon before reporting support capabilities', () => {
|
|
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
|
Object.assign(service as unknown as Record<string, unknown>, {
|
|
addon: null,
|
|
addonLoadError: null,
|
|
loadAddonModule,
|
|
getAddonCandidatePaths: () => [__filename],
|
|
getMissingRuntimeReason: () => null,
|
|
});
|
|
|
|
const support = service.getSupport();
|
|
|
|
expect(loadAddonModule).toHaveBeenCalledWith(__filename);
|
|
expect(support.supported).toBe(true);
|
|
expect(support.capabilities?.recording).toBe(true);
|
|
});
|
|
|
|
it('starts recording to a sanitized reserved unique ts file in the requested directory', () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(new Date(2026, 4, 9, 10, 11, 12));
|
|
const directory = createTempDir();
|
|
startSession('s1', snapshot('playing'));
|
|
|
|
const targetPath = path.join(directory, 'News_Live-20260509-101112.ts');
|
|
addon.getSessionSnapshot.mockReturnValueOnce(
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: true,
|
|
targetPath,
|
|
startedAt: '2026-05-09T08:11:12.000Z',
|
|
},
|
|
})
|
|
);
|
|
|
|
const updated = service.startRecording('s1', {
|
|
directory,
|
|
title: 'News/Live',
|
|
});
|
|
|
|
expect(addon.startRecording).toHaveBeenCalledWith('s1', targetPath);
|
|
expect(existsSync(targetPath)).toBe(true);
|
|
expect(updated?.recording?.active).toBe(true);
|
|
expect(updated?.recording?.targetPath).toBe(targetPath);
|
|
});
|
|
|
|
it('reserves the next unique recording path when the first candidate exists', () => {
|
|
jest.useFakeTimers();
|
|
jest.setSystemTime(new Date(2026, 4, 9, 10, 11, 12));
|
|
const directory = createTempDir();
|
|
const firstCandidate = path.join(
|
|
directory,
|
|
'News_Live-20260509-101112.ts'
|
|
);
|
|
const reservedCandidate = path.join(
|
|
directory,
|
|
'News_Live-20260509-101112-2.ts'
|
|
);
|
|
writeFileSync(firstCandidate, 'existing recording');
|
|
startSession('s1', snapshot('playing'));
|
|
|
|
addon.getSessionSnapshot.mockReturnValueOnce(
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: true,
|
|
targetPath: reservedCandidate,
|
|
},
|
|
})
|
|
);
|
|
|
|
service.startRecording('s1', {
|
|
directory,
|
|
title: 'News/Live',
|
|
});
|
|
|
|
expect(addon.startRecording).toHaveBeenCalledWith(
|
|
's1',
|
|
reservedCandidate
|
|
);
|
|
expect(existsSync(firstCandidate)).toBe(true);
|
|
expect(existsSync(reservedCandidate)).toBe(true);
|
|
});
|
|
|
|
it('stops recording and keeps the last target path in the session snapshot', () => {
|
|
const targetPath = '/tmp/News_Live-20260509-101112.ts';
|
|
startSession(
|
|
's1',
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: true,
|
|
targetPath,
|
|
startedAt: '2026-05-09T08:11:12.000Z',
|
|
},
|
|
})
|
|
);
|
|
addon.getSessionSnapshot.mockReturnValueOnce(
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: false,
|
|
targetPath,
|
|
},
|
|
})
|
|
);
|
|
|
|
const updated = service.stopRecording('s1');
|
|
|
|
expect(addon.stopRecording).toHaveBeenCalledWith('s1');
|
|
expect(updated?.recording?.active).toBe(false);
|
|
expect(updated?.recording?.targetPath).toBe(targetPath);
|
|
});
|
|
|
|
it('preserves the recording target path when disposing an active recording session', () => {
|
|
const targetPath = '/tmp/News_Live-20260509-101112.ts';
|
|
startSession(
|
|
's1',
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: true,
|
|
targetPath,
|
|
startedAt: '2026-05-09T08:11:12.000Z',
|
|
},
|
|
})
|
|
);
|
|
addon.getSessionSnapshot.mockReturnValueOnce(
|
|
snapshot('playing', {
|
|
recording: {
|
|
active: true,
|
|
targetPath,
|
|
startedAt: '2026-05-09T08:11:12.000Z',
|
|
},
|
|
})
|
|
);
|
|
|
|
const disposed = service.disposeSession('s1');
|
|
|
|
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
|
|
expect(disposed?.recording).toEqual({
|
|
active: false,
|
|
targetPath,
|
|
});
|
|
expect(mainWindowSendMock).toHaveBeenLastCalledWith(
|
|
'EMBEDDED_MPV_SESSION_UPDATE',
|
|
expect.objectContaining({
|
|
recording: {
|
|
active: false,
|
|
targetPath,
|
|
},
|
|
})
|
|
);
|
|
});
|
|
});
|