import { createServer, Server } from 'http'; import { readFileSync } from 'fs'; import { basename, join } from 'path'; import type { ExternalPlayerName, ExternalPlayerSession, PlayerContentInfo, } from '@iptvnator/shared/interfaces'; import { channelItemByTitle, closeElectronApp, expect, launchElectronApp, LaunchedElectronApp, openAddPlaylistDialog, test, waitForM3uCatalog, workspaceRoot, } from './electron-test-fixtures'; /** * DASH + ClearKey playback in the real Electron runtime — the only automated * proof that ClearKey EME works in the packaged `file://` renderer (secure * context). Uses the shared offline fixtures from apps/web-e2e/src/fixtures. */ const DASH_FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash'); const PLAYBACK_FIXTURE_DIR = join( workspaceRoot, 'apps/web-e2e/src/fixtures/playback' ); type PlaybackFixture = { readonly contentType: string; readonly path: string; }; const FIXTURE_FILES: ReadonlyMap = new Map([ [ 'clearkey.mpd', { contentType: 'application/dash+xml', path: join(DASH_FIXTURE_DIR, 'clearkey.mpd'), }, ], [ 'clearkey-video.mp4', { contentType: 'video/mp4', path: join(DASH_FIXTURE_DIR, 'clearkey-video.mp4'), }, ], [ 'clearkey-audio.mp4', { contentType: 'video/mp4', path: join(DASH_FIXTURE_DIR, 'clearkey-audio.mp4'), }, ], [ 'unsupported.mkv', { contentType: 'video/matroska', path: join(PLAYBACK_FIXTURE_DIR, 'unsupported.mkv'), }, ], ]); const CLEARKEY_KID = '00112233445566778899aabbccddeeff'; const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100'; type DashFixtureServer = { close: () => Promise; origin: string; }; /** Serves the explicit offline fixture allowlist with HTTP Range support * (Shaka fetches init segments and the sidx via byte ranges). */ async function startDashFixtureServer(): Promise { const server: Server = createServer((request, response) => { const pathname = (request.url ?? '').split('?')[0]; const fileName = basename(pathname); const fixture = FIXTURE_FILES.get(fileName); if (!fixture) { response.writeHead(404); response.end('not found'); return; } let body: Buffer; try { body = readFileSync(fixture.path); } catch { response.writeHead(404); response.end('not found'); return; } const range = /bytes=(\d+)-(\d+)?/.exec(request.headers.range ?? ''); if (!range) { response.writeHead(200, { 'Content-Type': fixture.contentType, 'Accept-Ranges': 'bytes', 'Access-Control-Allow-Origin': '*', 'Content-Length': body.length, }); response.end(body); return; } const start = Number(range[1]); const end = range[2] ? Number(range[2]) : body.length - 1; const chunk = body.subarray(start, end + 1); response.writeHead(206, { 'Content-Type': fixture.contentType, 'Accept-Ranges': 'bytes', 'Access-Control-Allow-Origin': '*', 'Content-Range': `bytes ${start}-${end}/${body.length}`, 'Content-Length': chunk.length, }); response.end(chunk); }); await new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolvePromise(); }); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Failed to resolve dash fixture server address.'); } return { origin: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolvePromise, reject) => { server.close((error) => error ? reject(error) : resolvePromise() ); }), }; } function buildDashPlaylist(origin: string): string { return [ '#EXTM3U', '#EXTINF:-1 tvg-id="ck-dash" group-title="DASH",ClearKey DASH', '#KODIPROP:inputstream.adaptive.license_type=clearkey', `#KODIPROP:inputstream.adaptive.license_key=${CLEARKEY_KID}:${CLEARKEY_KEY}`, `${origin}/clearkey.mpd`, '#EXTINF:-1 tvg-id="wv-dash" group-title="DASH",Widevine DASH', '#KODIPROP:inputstream.adaptive.license_type=com.widevine.alpha', '#KODIPROP:inputstream.adaptive.license_key=https://license.example.com/wv', `${origin}/clearkey.mpd`, '#EXTINF:-1 tvg-id="unsupported-mkv" group-title="DASH",Unsupported MKV', `${origin}/unsupported.mkv`, ].join('\n'); } async function importDashPlaylistFromText( app: LaunchedElectronApp, playlist: string ): Promise { await openAddPlaylistDialog(app.mainWindow); const dialog = app.mainWindow.locator('mat-dialog-container').last(); await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); await dialog.locator('textarea').fill(playlist); await dialog.getByRole('button', { name: 'Import', exact: true }).click(); await dialog.waitFor({ state: 'detached' }); await waitForM3uCatalog(app.mainWindow); } type ExternalPlayerIpcArguments = [ url: string, title: string, thumbnail: string | undefined, userAgent: string | undefined, referer: string | undefined, origin: string | undefined, contentInfo: PlayerContentInfo | undefined, startTime: number | undefined, headers: Record | undefined, ]; type CapturedExternalPlayerLaunch = { args: ExternalPlayerIpcArguments; player: ExternalPlayerName; }; type PlaybackRecommendationCaptureSnapshot = { closed: string[]; completed: number; launches: CapturedExternalPlayerLaunch[]; released: boolean; }; type PlaybackRecommendationCaptureState = PlaybackRecommendationCaptureSnapshot & { releaseFirstResponse: (() => void) | undefined; sessions: Record; waitForFirstResponse: Promise; }; async function installPlaybackRecommendationLaunchCapture( app: LaunchedElectronApp ): Promise { await app.electronApp.evaluate(({ ipcMain }) => { let releaseFirstResponse: (() => void) | undefined; const waitForFirstResponse = new Promise((resolve) => { releaseFirstResponse = resolve; }); const state: PlaybackRecommendationCaptureState = { closed: [], completed: 0, launches: [], released: false, releaseFirstResponse, sessions: {}, waitForFirstResponse, }; const globalRef = globalThis as typeof globalThis & { __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; }; globalRef.__playbackRecommendationCapture = state; const captureLaunch = (player: ExternalPlayerName) => async ( rawEvent: unknown, url: string, title: string, thumbnail?: string, userAgent?: string, referer?: string, origin?: string, contentInfo?: PlayerContentInfo, startTime?: number, headers?: Record ) => { const invocation = state.launches.push({ args: [ url, title, thumbnail, userAgent, referer, origin, contentInfo, startTime, headers, ], player, }); const now = new Date().toISOString(); const id = `e2e-recommended-${player}-${invocation}`; const event = rawEvent as { sender: { send: ( channel: string, session: ExternalPlayerSession ) => void; }; }; const launching: ExternalPlayerSession = { canClose: true, id, player, startedAt: now, status: 'launching', streamUrl: url, thumbnail: thumbnail ?? null, title, updatedAt: now, }; state.sessions[id] = launching; event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', launching); if (invocation === 1 && !state.released) { await state.waitForFirstResponse; } state.completed += 1; const completed: ExternalPlayerSession = { ...launching, canClose: player === 'mpv', error: player === 'vlc' ? 'E2E player launch failed' : undefined, player, status: player === 'mpv' ? 'opened' : 'error', updatedAt: new Date().toISOString(), }; state.sessions[id] = completed; event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', completed); return completed; }; ipcMain.removeHandler('OPEN_MPV_PLAYER'); ipcMain.removeHandler('OPEN_VLC_PLAYER'); ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv')); ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc')); ipcMain.removeHandler('CLOSE_EXTERNAL_PLAYER_SESSION'); ipcMain.handle( 'CLOSE_EXTERNAL_PLAYER_SESSION', (rawEvent: unknown, sessionId: string) => { const session = state.sessions[sessionId]; if (!session) return null; const closed: ExternalPlayerSession = { ...session, canClose: false, status: 'closed', updatedAt: new Date().toISOString(), }; state.closed.push(sessionId); state.sessions[sessionId] = closed; const event = rawEvent as { sender: { send: ( channel: string, update: ExternalPlayerSession ) => void; }; }; event.sender.send('EXTERNAL_PLAYER_SESSION_UPDATE', closed); return closed; } ); }); } async function getPlaybackRecommendationCapture( app: LaunchedElectronApp ): Promise { return app.electronApp.evaluate(() => { const globalRef = globalThis as typeof globalThis & { __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; }; const capture = globalRef.__playbackRecommendationCapture; if (!capture) { throw new Error( 'Playback recommendation capture is not installed.' ); } return { closed: capture.closed, completed: capture.completed, launches: capture.launches, released: capture.released, }; }); } async function releasePlaybackRecommendationCapture( app: LaunchedElectronApp ): Promise { await app.electronApp.evaluate(() => { const globalRef = globalThis as typeof globalThis & { __playbackRecommendationCapture?: PlaybackRecommendationCaptureState; }; const capture = globalRef.__playbackRecommendationCapture; if (!capture || capture.released) { return; } capture.released = true; capture.releaseFirstResponse?.(); capture.releaseFirstResponse = undefined; }); } test('@electron @dash ClearKey DASH filters DRM fallback and reports external launch states', async ({ dataDir, }) => { const fixtureServer = await startDashFixtureServer(); const app = await launchElectronApp(dataDir); try { const unsupportedFixtureResponse = await app.mainWindow.evaluate( async (url) => { const response = await fetch(url); const body = await response.text(); return { body, bodyBytes: new TextEncoder().encode(body).byteLength, contentLength: response.headers.get('content-length'), contentType: response.headers.get('content-type'), rendererProtocol: window.location.protocol, status: response.status, }; }, `${fixtureServer.origin}/unsupported.mkv` ); expect(unsupportedFixtureResponse).toEqual({ body: 'This fixture intentionally declares Matroska without playable media bytes.\n', bodyBytes: 75, contentLength: '75', contentType: 'video/matroska', rendererProtocol: 'file:', status: 200, }); await importDashPlaylistFromText( app, buildDashPlaylist(fixtureServer.origin) ); // Happy path: ClearKey EME decrypts and playback advances. await channelItemByTitle(app.mainWindow, 'ClearKey DASH') .first() .click(); const video = app.mainWindow .locator('app-web-player-view video') .first(); await expect(video).toBeVisible({ timeout: 15_000 }); await expect .poll( () => video.evaluate( (element: HTMLVideoElement) => element.currentTime ), { timeout: 20_000 } ) .toBeGreaterThan(0.5); await expect( app.mainWindow.getByTestId('playback-diagnostic-banner') ).toBeHidden(); // Negative: an unsupported license type must not crash — it shows the // DRM diagnostic instead. await channelItemByTitle(app.mainWindow, 'Widevine DASH') .first() .click(); const banner = app.mainWindow.getByTestId('playback-diagnostic-banner'); await expect(banner).toBeVisible({ timeout: 15_000 }); await expect(banner).toContainText(/encrypted or DRM-protected/i); await expect( banner.locator('[data-test-id="playback-fallback-mpv"]') ).toHaveCount(0); await expect( banner.locator('[data-test-id="playback-fallback-vlc"]') ).toHaveCount(0); await expect( banner.locator('[data-test-id^="playback-recommendation-"]') ).toHaveCount(0); const widevineDetails = banner.locator( '[data-test-id="playback-diagnostic-details"]' ); await widevineDetails.locator('summary').click(); await expect(widevineDetails).toContainText('drm-or-encryption'); await installPlaybackRecommendationLaunchCapture(app); await channelItemByTitle(app.mainWindow, 'Unsupported MKV') .first() .click(); await expect(banner).toContainText( /container is likely unsupported by the browser player/i, { timeout: 15_000 } ); const mkvDetails = banner.locator( '[data-test-id="playback-diagnostic-details"]' ); await mkvDetails.locator('summary').click(); await expect(mkvDetails).toContainText('unsupported-container'); const mpvFallback = banner.locator( '[data-test-id="playback-fallback-mpv"]' ); const vlcFallback = banner.locator( '[data-test-id="playback-fallback-vlc"]' ); await expect(mpvFallback).toBeVisible(); await expect(vlcFallback).toBeVisible(); await expect(mpvFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); await expect(vlcFallback).not.toHaveClass( /web-player-diagnostic__player-card--primary/ ); const expectedLaunches = [ { args: [ `${fixtureServer.origin}/unsupported.mkv`, 'Unsupported MKV', '', '', '', undefined, undefined, undefined, undefined, ], player: 'mpv', }, ] satisfies CapturedExternalPlayerLaunch[]; expect(await getPlaybackRecommendationCapture(app)).toEqual({ closed: [], completed: 0, launches: [], released: false, }); await mpvFallback.click(); await expect .poll(() => getPlaybackRecommendationCapture(app), { timeout: 10_000, }) .toEqual({ closed: [], completed: 0, launches: expectedLaunches, released: false, }); await expect(mpvFallback).toBeVisible(); await expect(vlcFallback).toBeVisible(); await expect(mpvFallback).toContainText('Opening MPV'); await expect(mpvFallback).toContainText('Opening player'); await expect(mpvFallback).toHaveAttribute('aria-busy', 'true'); await expect(vlcFallback).toHaveAttribute('aria-disabled', 'true'); await vlcFallback.evaluate((button: HTMLButtonElement) => button.click() ); await expect .poll(() => getPlaybackRecommendationCapture(app)) .toEqual({ closed: [], completed: 0, launches: expectedLaunches, released: false, }); const dock = app.mainWindow.locator('app-external-playback-dock'); await expect(dock).toContainText('Opening player'); await expect(dock.locator('.external-playback-dock')).toHaveAttribute( 'aria-busy', 'true' ); await releasePlaybackRecommendationCapture(app); await expect .poll(() => getPlaybackRecommendationCapture(app), { timeout: 10_000, }) .toEqual({ closed: [], completed: 1, launches: expectedLaunches, released: true, }); await app.mainWindow.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(() => resolve()) ); }) ); expect(await getPlaybackRecommendationCapture(app)).toEqual({ closed: [], completed: 1, launches: expectedLaunches, released: true, }); await expect(mpvFallback).toBeVisible(); await expect(mpvFallback).toContainText('Open MPV again'); await expect(mpvFallback).toContainText('Player started'); await expect(vlcFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); await expect(dock).toContainText('Player started'); await vlcFallback.click(); const expectedBothLaunches = [ ...expectedLaunches, { ...expectedLaunches[0], player: 'vlc' as const }, ]; await expect .poll(() => getPlaybackRecommendationCapture(app), { timeout: 10_000, }) .toEqual({ closed: ['e2e-recommended-mpv-1'], completed: 2, launches: expectedBothLaunches, released: true, }); await expect(mpvFallback).toBeVisible(); await expect(vlcFallback).toBeVisible(); await expect(vlcFallback).toContainText('Try VLC again'); await expect(vlcFallback).toContainText('Player launch failed'); await expect(mpvFallback).toHaveClass( /web-player-diagnostic__player-card--primary/ ); await expect(dock).toContainText('E2E player launch failed'); await expect( dock.getByRole('button', { name: 'Dismiss' }) ).toBeVisible(); await dock.getByRole('button', { name: 'Dismiss' }).click(); await expect(dock).toBeHidden(); } finally { await releasePlaybackRecommendationCapture(app).catch(() => undefined); await closeElectronApp(app); await fixtureServer.close(); } });