Files
iptvnator/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
T

607 lines
21 KiB
TypeScript

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<string, PlaybackFixture> = 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<void>;
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<DashFixtureServer> {
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<void>((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<void>((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<void> {
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<string, string> | 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<string, ExternalPlayerSession>;
waitForFirstResponse: Promise<void>;
};
async function installPlaybackRecommendationLaunchCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate(({ ipcMain }) => {
let releaseFirstResponse: (() => void) | undefined;
const waitForFirstResponse = new Promise<void>((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<string, string>
) => {
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<PlaybackRecommendationCaptureSnapshot> {
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<void> {
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<void>((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();
}
});