test(e2e): add offline DASH ClearKey fixtures and e2e coverage

Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and
CENC-encrypted variants with fixed synthetic ClearKey credentials.
Content synthesized by ffmpeg; encryption done by Shaka Packager because
ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio)
and cannot produce the subsample encryption the VP9 CENC binding
requires. Generation script + README document regeneration.

web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text,
verify encrypted and clear DASH actually play (currentTime advances, no
diagnostic banner) and that an unsupported license type (Widevine)
surfaces the DRM diagnostic. Fixtures are served through Playwright route
interception with HTTP Range support; the Angular service worker is
blocked since SW-routed requests bypass interception.

electron-backend-e2e: the same happy path + negative against a local
Range-aware fixture server — the automated proof that ClearKey EME works
in the real Electron runtime (file:// secure context).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-18 19:16:52 +02:00
1 parent 5d87e89ee3
commit b838e2c2dd
10 files changed
+564

No files matched your search

@@ -0,0 +1,172 @@
import { createServer, Server } from 'http';
import { readFileSync } from 'fs';
import { basename, join } from 'path';
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 FIXTURE_DIR = join(workspaceRoot, 'apps/web-e2e/src/fixtures/dash');
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
type DashFixtureServer = {
close: () => Promise<void>;
origin: string;
};
/** Serves the DASH fixture directory 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);
let body: Buffer;
try {
body = readFileSync(join(FIXTURE_DIR, fileName));
} catch {
response.writeHead(404);
response.end('not found');
return;
}
const contentType = fileName.endsWith('.mpd')
? 'application/dash+xml'
: 'video/mp4';
const range = /bytes=(\d+)-(\d+)?/.exec(
request.headers.range ?? ''
);
if (!range) {
response.writeHead(200, {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
'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': contentType,
'Accept-Ranges': 'bytes',
'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`,
].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);
}
test('@electron @dash ClearKey DASH plays inline and unsupported DRM surfaces a diagnostic', async ({
dataDir,
}) => {
const fixtureServer = await startDashFixtureServer();
const app = await launchElectronApp(dataDir);
try {
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);
} finally {
await closeElectronApp(app);
await fixtureServer.close();
}
});
+153
View File
@@ -0,0 +1,153 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
/**
* DASH + ClearKey playback (offline fixture, no network).
*
* The fixture host is virtual: every request to it is fulfilled from
* `fixtures/dash/` via route interception, including HTTP Range requests
* (Shaka fetches the init segment and sidx via byte ranges).
*/
const FIXTURE_DIR = join(__dirname, 'fixtures/dash');
const FIXTURE_HOST = 'https://dash-fixture.local';
const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
const DASH_PLAYLIST = [
'#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}`,
`${FIXTURE_HOST}/clearkey.mpd`,
'#EXTINF:-1 tvg-id="clear-dash" group-title="DASH",Clear DASH',
`${FIXTURE_HOST}/clear.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',
`${FIXTURE_HOST}/clearkey.mpd`,
].join('\n');
// The inline player starts playback programmatically; without this flag the
// bundled Chromium blocks play() before a user gesture reaches the video.
// The Angular service worker must be blocked: requests going through it
// bypass Playwright route interception, so the virtual fixture host would
// never resolve.
test.use({
launchOptions: {
args: ['--autoplay-policy=no-user-gesture-required'],
},
serviceWorkers: 'block',
});
// ClearKey EME + VP9 support is only deterministic in Chromium among the
// bundled Playwright browsers (WebKit lacks ClearKey); the Electron e2e suite
// covers the real desktop runtime.
test.skip(
({ browserName }) => browserName !== 'chromium',
'DASH ClearKey coverage targets Chromium'
);
async function serveDashFixtures(page: Page): Promise<void> {
await page.route(`${FIXTURE_HOST}/**`, async (route) => {
const url = new URL(route.request().url());
let body: Buffer;
try {
body = readFileSync(join(FIXTURE_DIR, url.pathname.slice(1)));
} catch {
await route.fulfill({ status: 404, body: 'not found' });
return;
}
const contentType = url.pathname.endsWith('.mpd')
? 'application/dash+xml'
: 'video/mp4';
const rangeHeader = route.request().headers()['range'];
const range = rangeHeader
? /bytes=(\d+)-(\d+)?/.exec(rangeHeader)
: null;
if (!range) {
await route.fulfill({
status: 200,
headers: {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
},
body,
});
return;
}
const start = Number(range[1]);
const end = range[2] ? Number(range[2]) : body.length - 1;
await route.fulfill({
status: 206,
headers: {
'Content-Type': contentType,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${start}-${end}/${body.length}`,
},
body: body.subarray(start, end + 1),
});
});
}
async function importDashPlaylist(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.locator('textarea').fill(DASH_PLAYLIST);
await Promise.all([
page.waitForURL(/\/workspace\/playlists\/.+\/all$/),
dialog.getByRole('button', { name: 'Import', exact: true }).click(),
]);
await expect(page.getByText('3 channels')).toBeVisible();
}
async function expectVideoPlaying(page: Page): Promise<void> {
const video = page.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(
page.locator('[data-test-id="playback-diagnostic-banner"]')
).toBeHidden();
}
test('@web @m3u @dash ClearKey and clear DASH channels play inline', async ({
page,
}) => {
await serveDashFixtures(page);
await importDashPlaylist(page);
await page.getByText('1. ClearKey DASH').click();
await expectVideoPlaying(page);
await page.getByText('2. Clear DASH').click();
await expectVideoPlaying(page);
});
test('@web @m3u @dash unsupported DRM shows the encryption diagnostic', async ({
page,
}) => {
await serveDashFixtures(page);
await importDashPlaylist(page);
await page.getByText('3. Widevine DASH').click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 15_000 });
await expect(banner).toContainText(/encrypted or DRM-protected/i);
});
+49
View File
@@ -0,0 +1,49 @@
# Offline DASH ClearKey fixtures
Deterministic ~4 s DASH assets used by the DASH/ClearKey e2e suites
(`apps/web-e2e/src/dash-clearkey.e2e.ts` and the Electron equivalent):
| File | Content |
| -------------------- | ---------------------------------------------------- |
| `clearkey-video.mp4` | VP9 video, CENC (`cenc` AES-CTR, subsample) encrypted |
| `clearkey-audio.mp4` | Opus audio, CENC encrypted |
| `clearkey.mpd` | Static on-demand MPD for the encrypted pair |
| `clear-video.mp4` | VP9 video, unencrypted (clear-DASH regression case) |
| `clear-audio.mp4` | Opus audio, unencrypted |
| `clear.mpd` | Static on-demand MPD for the clear pair |
Fixed ClearKey test credentials (obviously synthetic, safe to commit):
- KID: `00112233445566778899aabbccddeeff`
- KEY: `ffeeddccbbaa99887766554433221100`
They correspond to the `#KODIPROP:inputstream.adaptive.license_key=KID:KEY`
value used by the e2e playlists.
**Why VP9+Opus:** Playwright's bundled Chromium ships without proprietary
codecs (no H.264/AAC), while royalty-free VP9/Opus decode both there and in
Electron — one fixture serves both suites.
**Why Shaka Packager for encryption:** ffmpeg's mp4 muxer only writes `senc`
sample-encryption metadata — Chromium's demuxer requires `saiz`/`saio` and
fails with `CHUNK_DEMUXER_ERROR_APPEND_FAILED: Sample encryption info is not
available`. ffmpeg also cannot produce the subsample encryption that the VP9
CENC binding mandates (a fully-encrypted VP9 track ends in
`MEDIA_ERR_DECODE`). Shaka Packager produces spec-compliant output for both.
## Regeneration
```bash
node apps/web-e2e/src/fixtures/dash/generate-fixture.mjs
```
Requires `ffmpeg` (tested with 7.x) with `libvpx-vp9` and `libopus`. Shaka
Packager is resolved in this order: the `SHAKA_PACKAGER` env var (path to a
`packager` binary), an installed `shaka-packager` npm package, otherwise the
script fetches the official npm package (prebuilt per-platform binaries) once
into a temp directory via `npm pack`.
The script synthesizes the content with ffmpeg (`testsrc2` + `sine`, clear
master), then packages both variants with Shaka Packager, which also writes
the MPDs. Byte-exact output across tool versions is not guaranteed; the
committed files are the source of truth.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generated with https://github.com/shaka-project/shaka-packager version v3.9.2-9397a68-release-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" minBufferTime="PT2S" type="static" mediaPresentationDuration="PT4S">
<Period id="0">
<AdaptationSet id="0" contentType="video" width="320" height="180" frameRate="12288/512" subsegmentAlignment="true" par="16:9">
<Representation id="0" bandwidth="146192" codecs="vp09.00.11.08.01.02.02.02.00" mimeType="video/mp4" sar="1:1">
<BaseURL>clear-video.mp4</BaseURL>
<SegmentBase indexRange="807-850" timescale="12288">
<Initialization range="0-806"/>
</SegmentBase>
</Representation>
</AdaptationSet>
<AdaptationSet id="1" contentType="audio" subsegmentAlignment="true">
<Representation id="1" bandwidth="61348" codecs="opus" mimeType="audio/mp4" audioSamplingRate="48000">
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="1"/>
<BaseURL>clear-audio.mp4</BaseURL>
<SegmentBase indexRange="810-853" timescale="48000">
<Initialization range="0-809"/>
</SegmentBase>
</Representation>
</AdaptationSet>
</Period>
</MPD>
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generated with https://github.com/shaka-project/shaka-packager version v3.9.2-9397a68-release-->
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="urn:mpeg:dash:schema:mpd:2011 DASH-MPD.xsd" xmlns:cenc="urn:mpeg:cenc:2013" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011" minBufferTime="PT2S" type="static" mediaPresentationDuration="PT4S">
<Period id="0">
<AdaptationSet id="0" contentType="video" width="320" height="180" frameRate="12288/512" subsegmentAlignment="true" par="16:9">
<ContentProtection value="cenc" schemeIdUri="urn:mpeg:dash:mp4protection:2011" cenc:default_KID="00112233-4455-6677-8899-aabbccddeeff"/>
<ContentProtection schemeIdUri="urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b">
<cenc:pssh>AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEAESIzRFVmd4iZqrvM3e7/AAAAAA==</cenc:pssh>
</ContentProtection>
<Representation id="0" bandwidth="149370" codecs="vp09.00.11.08.01.02.02.02.00" mimeType="video/mp4" sar="1:1">
<BaseURL>clearkey-video.mp4</BaseURL>
<SegmentBase indexRange="939-982" timescale="12288">
<Initialization range="0-938"/>
</SegmentBase>
</Representation>
</AdaptationSet>
<AdaptationSet id="1" contentType="audio" subsegmentAlignment="true">
<ContentProtection value="cenc" schemeIdUri="urn:mpeg:dash:mp4protection:2011" cenc:default_KID="00112233-4455-6677-8899-aabbccddeeff"/>
<ContentProtection schemeIdUri="urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b">
<cenc:pssh>AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEAESIzRFVmd4iZqrvM3e7/AAAAAA==</cenc:pssh>
</ContentProtection>
<Representation id="1" bandwidth="64670" codecs="opus" mimeType="audio/mp4" audioSamplingRate="48000">
<AudioChannelConfiguration schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011" value="1"/>
<BaseURL>clearkey-audio.mp4</BaseURL>
<SegmentBase indexRange="942-985" timescale="48000">
<Initialization range="0-941"/>
</SegmentBase>
</Representation>
</AdaptationSet>
</Period>
</MPD>
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Regenerates the offline DASH ClearKey fixtures used by the DASH e2e suites.
*
* Produces, next to this script:
* - clearkey-video.mp4 / clearkey-audio.mp4 / clearkey.mpd —
* CENC (`cenc` AES-CTR) encrypted VP9 video + Opus audio. Encryption is
* done by Shaka Packager because ffmpeg's mp4 muxer only writes `senc`
* sample-encryption metadata (Chromium requires `saiz`/`saio`) and cannot
* produce the subsample encryption that the VP9 CENC binding mandates.
* - clear-video.mp4 / clear-audio.mp4 / clear.mpd — same content, clear.
*
* VP9+Opus is deliberate: Playwright's bundled Chromium ships no proprietary
* codecs (H.264/AAC), while royalty-free codecs work there and in Electron.
*
* Requirements:
* - ffmpeg (tested with 7.x) built with libvpx-vp9 and libopus
* - Shaka Packager: either set SHAKA_PACKAGER=/path/to/packager, install
* the `shaka-packager` npm package, or let this script fetch it once via
* `npm pack shaka-packager` into a temp directory (official Google
* package with prebuilt per-platform binaries).
*
* Byte-exact output across tool versions is not guaranteed — the committed
* files are the source of truth for CI.
*
* Usage: node apps/web-e2e/src/fixtures/dash/generate-fixture.mjs
*/
import { execFileSync, execSync } from 'node:child_process';
import { chmodSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const OUT_DIR = dirname(fileURLToPath(import.meta.url));
/** Fixed, obviously synthetic 128-bit ClearKey test credentials. */
export const CLEARKEY_KID = '00112233445566778899aabbccddeeff';
export const CLEARKEY_KEY = 'ffeeddccbbaa99887766554433221100';
const DURATION_SECONDS = 4;
const FRAME_RATE = 24;
const masterPath = join(OUT_DIR, 'content-master.tmp.mp4');
const packager = resolvePackager();
try {
// 1. Synthesize the clear master (muxed VP9+Opus).
execFileSync(
'ffmpeg',
[
'-y',
'-f', 'lavfi', '-i',
`testsrc2=duration=${DURATION_SECONDS}:size=320x180:rate=${FRAME_RATE}`,
'-f', 'lavfi', '-i',
`sine=frequency=440:duration=${DURATION_SECONDS}`,
'-c:v', 'libvpx-vp9', '-b:v', '150k', '-g', String(FRAME_RATE),
'-c:a', 'libopus', '-b:a', '48k',
masterPath,
],
{ stdio: ['ignore', 'ignore', 'inherit'] }
);
// 2. Encrypted variant (subsample CENC for VP9, on-demand profile MPD).
runPackager([
`in=${masterPath},stream=video,output=${join(OUT_DIR, 'clearkey-video.mp4')},drm_label=CK`,
`in=${masterPath},stream=audio,output=${join(OUT_DIR, 'clearkey-audio.mp4')},drm_label=CK`,
'--enable_raw_key_encryption',
'--keys', `label=CK:key_id=${CLEARKEY_KID}:key=${CLEARKEY_KEY}`,
'--clear_lead', '0',
'--protection_scheme', 'cenc',
'--mpd_output', join(OUT_DIR, 'clearkey.mpd'),
]);
console.log(`clearkey.mpd: CENC VP9+Opus (kid=${CLEARKEY_KID})`);
// 3. Clear variant.
runPackager([
`in=${masterPath},stream=video,output=${join(OUT_DIR, 'clear-video.mp4')}`,
`in=${masterPath},stream=audio,output=${join(OUT_DIR, 'clear-audio.mp4')}`,
'--mpd_output', join(OUT_DIR, 'clear.mpd'),
]);
console.log('clear.mpd: clear VP9+Opus');
} finally {
rmSync(masterPath, { force: true });
}
function runPackager(args) {
execFileSync(packager.command, [...packager.prefixArgs, ...args], {
stdio: ['ignore', 'ignore', 'inherit'],
});
}
function resolvePackager() {
if (process.env.SHAKA_PACKAGER) {
return { command: process.env.SHAKA_PACKAGER, prefixArgs: [] };
}
try {
const launcher = createRequire(import.meta.url).resolve(
'shaka-packager'
);
return { command: process.execPath, prefixArgs: [launcher] };
} catch {
return fetchPackagerViaNpmPack();
}
}
/** One-off fetch of the official npm package with prebuilt binaries. */
function fetchPackagerViaNpmPack() {
const workDir = mkdtempSync(join(tmpdir(), 'shaka-packager-'));
console.log(`Fetching shaka-packager via npm pack into ${workDir} ...`);
execSync('npm pack shaka-packager --silent', {
cwd: workDir,
stdio: ['ignore', 'ignore', 'inherit'],
});
const tarball = readdirSync(workDir).find((name) =>
name.endsWith('.tgz')
);
execSync(`tar -xzf ${JSON.stringify(tarball)}`, { cwd: workDir });
const binaryName = {
darwin: { arm64: 'packager-osx-arm64', x64: 'packager-osx-x64' },
linux: { arm64: 'packager-linux-arm64', x64: 'packager-linux-x64' },
win32: { x64: 'packager-win-x64.exe' },
}[process.platform]?.[process.arch];
if (!binaryName) {
throw new Error(
`No shaka-packager binary for ${process.platform}/${process.arch}; set SHAKA_PACKAGER manually.`
);
}
const binaryPath = join(workDir, 'package', 'bin', binaryName);
chmodSync(binaryPath, 0o755);
return { command: binaryPath, prefixArgs: [] };
}