import { expect, launchPackagedElectronApp, resolvePackagedLinuxExecutable, test, type LaunchedElectronApp, } from './electron-test-fixtures'; import { readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; import { assertNativeFallbackPrerequisites, cleanupPackagedFrameCopySmoke, closeAndWaitForExit, createLocalMediaServer, createWavFixture, getEmbeddedMpvSupport, getLatestSession, installEmbeddedMpvSessionCapture, installFrameCanvasAndSessionCapture, isMeaningfulNativePlaybackSnapshot, renderedFrameSignal, type LocalMediaServer, } from './embedded-mpv-frame-copy-packaged-fixtures'; import { createDisposablePackagedLinuxApp, createPackagedEntryGuard, readPackagedRuntimeIdentity, type DisposablePackagedLinuxApp, type PackagedEntryGuard, } from './embedded-mpv-frame-copy-packaged-filesystem'; const PACKAGED_FRAME_COPY_REQUIRED_ENV = 'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY'; const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY'; const packagedExecutable = resolvePackagedLinuxExecutable(); const packagedFrameCopyRequired = isTruthy( process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV] ); function isTruthy(value: string | undefined): boolean { return ['1', 'true', 'yes', 'on'].includes( (value ?? '').trim().toLowerCase() ); } // This smoke drives the public preload API directly so it exercises the real // packaged main process, helper, frame reader, WebGL pump, and pause controls // without coupling runtime validation to playlist import/settings UI state. test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { test.skip( process.platform !== 'linux', 'The packaged frame-copy runtime is Linux-only.' ); test.skip( !packagedExecutable && !packagedFrameCopyRequired, `Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.` ); test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({ dataDir, }) => { expect( process.arch, 'The official Linux frame-copy runtime is x64-only.' ).toBe('x64'); expect( packagedExecutable, `The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.` ).toBeTruthy(); const sourceExecutablePath = packagedExecutable as string; assertNativeFallbackPrerequisites(); let packageClone: DisposablePackagedLinuxApp | undefined; let hiddenRuntimeEntry: PackagedEntryGuard | undefined; let media: LocalMediaServer | undefined; let frameCopyApp: LaunchedElectronApp | undefined; let fallbackApp: LaunchedElectronApp | undefined; try { packageClone = createDisposablePackagedLinuxApp(sourceExecutablePath); const executablePath = packageClone.executablePath; const nativeDir = packageClone.nativeDir; const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); hiddenRuntimeEntry = createPackagedEntryGuard( join(nativeDir, 'lib', runtimeIdentity.libmpvSoname), { expectedKind: 'regular-file', hiddenDirectory: packageClone.temporaryRoot, } ); const mediaServer = await createLocalMediaServer(); media = mediaServer; expect(runtimeIdentity).toMatchObject({ arch: 'x64', platform: 'linux', runtimeMode: 'bundled', }); expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile); const launchedFrameCopyApp = await launchPackagedElectronApp( executablePath, dataDir, { env: { [FRAME_COPY_OPT_IN_ENV]: '1', }, } ); frameCopyApp = launchedFrameCopyApp; await expect .poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp)) .toMatchObject({ engine: 'frame-copy', frameCopyAvailable: true, platform: 'linux', supported: true, }); await installFrameCanvasAndSessionCapture(launchedFrameCopyApp); const created = await launchedFrameCopyApp.mainWindow.evaluate( async () => { // Decode audio without requiring a sound device on CI. await window.electron.updateSettings({ embeddedMpvExtraOptions: 'ao=null', }); return window.electron.createEmbeddedMpvSession( { x: 0, y: 0, width: 320, height: 180 }, 'Packaged frame-copy smoke', 0 ); } ); await launchedFrameCopyApp.mainWindow.evaluate( async ({ sessionId, streamUrl }) => { await window.electron.setEmbeddedMpvPaused(sessionId, true); await window.electron.loadEmbeddedMpvPlayback(sessionId, { streamUrl, title: 'Generated Y4M fixture', isLive: false, }); }, { sessionId: created.id, streamUrl: mediaServer.url } ); await expect .poll( () => getLatestSession(launchedFrameCopyApp, created.id), { timeout: 15000, } ) .toMatchObject({ status: 'paused', streamUrl: mediaServer.url, videoHeight: 36, videoWidth: 64, }); const attached = await launchedFrameCopyApp.mainWindow.evaluate( async (sessionId) => { return window.electron.attachEmbeddedMpvFrameView?.( sessionId ); }, created.id ); expect(attached).toBe(true); await expect( launchedFrameCopyApp.mainWindow.getByTestId( 'packaged-embedded-mpv-frame' ) ).toHaveAttribute('width', '320'); await expect( launchedFrameCopyApp.mainWindow.getByTestId( 'packaged-embedded-mpv-frame' ) ).toHaveAttribute('height', '180'); await expect .poll(() => renderedFrameSignal(launchedFrameCopyApp), { timeout: 15000, }) .toBeGreaterThan(0); // Relative seek steps (arrow keys, ±10 s buttons) must reach mpv // as `seek relative+exact` through the real preload → // main-process → helper `seek-by` path, and a burst issued without // waiting for snapshots has to accumulate. Absolute targets // computed from the stale renderer snapshot collapsed such bursts // onto one target (about one second of progress per press). await launchedFrameCopyApp.mainWindow.evaluate( async (sessionId) => { const seekBy = window.electron.seekEmbeddedMpvBy; if (!seekBy) { throw new Error( 'seekEmbeddedMpvBy is missing from the preload bridge' ); } await Promise.all([ seekBy(sessionId, 2), seekBy(sessionId, 2), seekBy(sessionId, 2), ]); }, created.id ); await expect .poll( async () => ( await getLatestSession( launchedFrameCopyApp, created.id ) )?.positionSeconds, { timeout: 15000 } ) .toBe(6); // Stepping back past the start clamps at zero instead of failing. await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.seekEmbeddedMpvBy?.(sessionId, -60), created.id ); await expect .poll( async () => ( await getLatestSession( launchedFrameCopyApp, created.id ) )?.positionSeconds, { timeout: 15000 } ) .toBe(0); await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.setEmbeddedMpvPaused(sessionId, false), created.id ); await expect .poll( async () => ( await getLatestSession( launchedFrameCopyApp, created.id ) )?.status, { timeout: 10000 } ) .toBe('playing'); // Advanced subtitle support (#1408): exercise the real preload // IPC, main-process validation, and the helper's `sub-add` // protocol command against the packaged runtime. The native file // dialog cannot be automated, so the path-based IPC is driven // directly with a fixture file; mpv parses and auto-selects it, // and the helper's track-list observation must report it back. const subtitlePath = join(dataDir, 'packaged-smoke.srt'); writeFileSync( subtitlePath, '1\n00:00:00,000 --> 00:00:10,000\nPackaged subtitle smoke\n' ); const withSubtitle = await launchedFrameCopyApp.mainWindow.evaluate( async ({ sessionId, path }) => window.electron.addEmbeddedMpvSubtitle?.(sessionId, path), { sessionId: created.id, path: subtitlePath } ); expect(withSubtitle?.id).toBe(created.id); await expect .poll( async () => { const session = await getLatestSession( launchedFrameCopyApp, created.id ); return session?.subtitleTracks?.length ?? 0; }, { timeout: 15000 } ) .toBeGreaterThan(0); // Delay and style ride the same session-guarded IPC; mpv does not // echo the values in the snapshot, so a session reply for the live // session (instead of a rejection) is the observable contract. const delayReply = await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.setEmbeddedMpvSubtitleDelay?.( sessionId, 1.5 ), created.id ); expect(delayReply?.id).toBe(created.id); const styleReply = await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.setEmbeddedMpvSubtitleStyle?.(sessionId, { sizePercent: 150, color: '#ffe94f', }), created.id ); expect(styleReply?.id).toBe(created.id); await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.setEmbeddedMpvPaused(sessionId, true), created.id ); await expect .poll( async () => ( await getLatestSession( launchedFrameCopyApp, created.id ) )?.status, { timeout: 10000 } ) .toBe('paused'); // These values must originate in real libmpv property observations, // survive helper JSON/preload mapping, and change with the source. await expect .poll( async () => ( await getLatestSession( launchedFrameCopyApp, created.id ) )?.stats ) .toMatchObject({ videoCodec: expect.any(String), container: expect.any(String), droppedFrames: expect.any(Number), }); const originalStats = (await getLatestSession( launchedFrameCopyApp, created.id ))!.stats!; const alternateMedia = await createLocalMediaServer({ body: readFileSync( join( __dirname, '../../web-e2e/src/fixtures/playback/episode.webm' ) ), resourcePath: '/stream-stats-switch.webm', contentType: 'video/webm', }); media = { url: mediaServer.url, close: async () => { await Promise.all([ mediaServer.close(), alternateMedia.close(), ]); }, }; const audioMedia = await createLocalMediaServer({ body: createWavFixture(), resourcePath: '/stream-stats-audio.wav', contentType: 'audio/wav', }); const videoMedia = media; media = { url: videoMedia.url, close: async () => { await Promise.all([videoMedia.close(), audioMedia.close()]); }, }; // mpv uses its built-in demuxer name "mkv" for WebM. for (const [streamUrl, videoCodec, container] of [ [alternateMedia.url, 'vp8', 'mkv'], [ mediaServer.url, originalStats.videoCodec, originalStats.container, ], ] as const) { await launchedFrameCopyApp.mainWindow.evaluate( async ({ sessionId, streamUrl }) => { await window.electron.loadEmbeddedMpvPlayback( sessionId, { streamUrl, title: 'Stream stats source switch', isLive: false, } ); }, { sessionId: created.id, streamUrl } ); await expect .poll( () => getLatestSession(launchedFrameCopyApp, created.id), { timeout: 15000 } ) .toMatchObject({ streamUrl, stats: { videoCodec, container }, }); } // Audio-only replacements must not retain the previous video size. await launchedFrameCopyApp.mainWindow.evaluate( async ({ sessionId, streamUrl }) => { await window.electron.loadEmbeddedMpvPlayback(sessionId, { streamUrl, title: 'Audio-only statistics', isLive: false, }); }, { sessionId: created.id, streamUrl: audioMedia.url } ); await expect .poll( () => getLatestSession(launchedFrameCopyApp, created.id), { timeout: 15000 } ) .toMatchObject({ streamUrl: audioMedia.url, videoWidth: 0, videoHeight: 0, stats: { audioCodec: 'pcm_s16le', audioChannels: expect.any(String), audioSampleRateHz: 8000, }, }); // Revoking the only audio track emits MPV_FORMAT_NONE without a // START_FILE reset. Previously these values stayed cached forever. await launchedFrameCopyApp.mainWindow.evaluate( async (sessionId) => { await window.electron.setEmbeddedMpvAudioTrack( sessionId, -1 ); }, created.id ); await expect .poll(async () => { const stats = ( await getLatestSession(launchedFrameCopyApp, created.id) )?.stats; return { codec: stats?.audioCodec ?? null, channels: stats?.audioChannels ?? null, sampleRate: stats?.audioSampleRateHz ?? null, }; }) .toEqual({ codec: null, channels: null, sampleRate: null }); await launchedFrameCopyApp.mainWindow.evaluate( async (sessionId) => { window.electron.detachEmbeddedMpvFrameView?.(); await window.electron.disposeEmbeddedMpvSession(sessionId); window.__packagedEmbeddedMpvUnsubscribe?.(); }, created.id ); await closeAndWaitForExit(launchedFrameCopyApp); frameCopyApp = undefined; hiddenRuntimeEntry.hide(); expect(readPackagedRuntimeIdentity(nativeDir)).toEqual( runtimeIdentity ); const launchedFallbackApp = await launchPackagedElectronApp( executablePath, dataDir, { env: { [FRAME_COPY_OPT_IN_ENV]: '1', }, } ); fallbackApp = launchedFallbackApp; const fallbackSupport = await getEmbeddedMpvSupport(launchedFallbackApp); expect(fallbackSupport).toMatchObject({ engine: 'native', frameCopyAvailable: false, frameCopyUnavailableReason: 'runtime-library-missing', platform: 'linux', supported: true, }); await installEmbeddedMpvSessionCapture(launchedFallbackApp); const nativeSession = await launchedFallbackApp.mainWindow.evaluate( async () => { return window.electron.createEmbeddedMpvSession( { x: 0, y: 0, width: 320, height: 180 }, 'Native-view fallback smoke', 0 ); } ); expect(nativeSession.id).toMatch(/^embedded-mpv-/); await launchedFallbackApp.mainWindow.evaluate( async ({ sessionId, streamUrl }) => { await window.electron.loadEmbeddedMpvPlayback(sessionId, { streamUrl, title: 'Native-view generated Y4M fixture', isLive: false, }); }, { sessionId: nativeSession.id, streamUrl: mediaServer.url } ); await expect .poll( async () => isMeaningfulNativePlaybackSnapshot( await getLatestSession( launchedFallbackApp, nativeSession.id ), mediaServer.url ), { timeout: 15000 } ) .toBe(true); expect( launchedFallbackApp.electronApp.process().exitCode ).toBeNull(); expect( launchedFallbackApp.electronApp.process().signalCode ).toBeNull(); await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => { await window.electron.disposeEmbeddedMpvSession(sessionId); window.__packagedEmbeddedMpvUnsubscribe?.(); }, nativeSession.id); await expect .poll(() => launchedFallbackApp.mainWindow.title()) .toContain('IPTVnator'); } finally { await cleanupPackagedFrameCopySmoke({ apps: [frameCopyApp, fallbackApp], hiddenRuntimeEntry, media, packageClone, }); } }); });