Files
iptvnator/apps/web-e2e/src/dash-clearkey.e2e.ts
T
4grayandClaude Opus 5.5 a8dd1eaa97 fix(import): consistent add-source forms with masked passwords and URL errors (#1796)
* fix(import): consistent add-source forms with masked passwords and URL errors

- Mask the Xtream password in add and edit (and the Stalker one in edit)
  behind a shared PasswordVisibilityToggleDirective: one translated
  "Show password" label, state in aria-pressed, type="button".
- Give the Xtream server URL its own mat-error and a neutral hint instead
  of the EPG file error; give the M3U URL a mat-error.
- Use "Playlist title" in every add form, "MAC address" casing, a single
  ellipsis in "Validating portal…" and one "Add playlist" submit label;
  translate the method radiogroup's aria-label.
- Show Stalker refusals inline under the portal URL (role="status", like
  the Xtream connection test), translated in the template and cleared by
  edits; translate the snackbars for outcomes that close the dialog.
- Translate new strings into all locales; reuse the identical Stalker URL
  error translations; fix MAC casing and ellipses; drop unused keys.
- Unit specs per form, edit-dialog spec, new add-source-forms web E2E;
  update E2E locators; UI guidelines Forms section; Stalker contract.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): mask the password again when an add form is cleared

Clear erased the password but left the visibility toggle on, so the next
password typed in the Xtream or Stalker form showed in plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:13:54 +02:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
// Regression for #1466: accept standard Base64 JSON without padding in PWA.
const CLEARKEY_LICENSE = JSON.stringify({
keys: [
{
kty: 'oct',
kid: Buffer.from(CLEARKEY_KID, 'hex')
.toString('base64')
.replace(/=+$/, ''),
k: Buffer.from(CLEARKEY_KEY, 'hex')
.toString('base64')
.replace(/=+$/, ''),
},
],
type: 'temporary',
});
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_LICENSE}`,
`${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?widevine=1`,
].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'
);
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function () {
// Keep real decoding while avoiding the host's audio-output clock.
// Muting must happen before play, including replacement players.
this.muted = true;
return play.call(this);
};
});
});
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: 'Add playlist', 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);
const clearKeyVideo = await page
.locator('app-web-player-view video')
.first()
.elementHandle();
if (!clearKeyVideo) {
throw new Error('Missing playing ClearKey video');
}
await page.getByText('2. Clear DASH').click();
// The new source mounts asynchronously; never accept the old video's time.
await page.waitForFunction(
(previous) => !previous.isConnected,
clearKeyVideo
);
await expectVideoPlaying(page);
});
test('@web @m3u @dash ClearKey reopens from recent and favorites collections', async ({
page,
}) => {
// Five cold loads of the dev-served app (import + four collection routes)
// cost ~6 s each on the CI runner, so the default 30 s budget expired on
// the last route (#1630). Sized like the other multi-load specs.
test.setTimeout(90_000);
await serveDashFixtures(page);
await importDashPlaylist(page);
await page.getByText('1. ClearKey DASH').click();
await expectVideoPlaying(page);
// Recent history records the channel once it has played two seconds.
await expect
.poll(
() =>
page
.locator('app-web-player-view video')
.first()
.evaluate(
(element: HTMLVideoElement) => element.currentTime
),
{ timeout: 20_000 }
)
.toBeGreaterThan(2.5);
const playlistUrl = page.url().replace(/\/all$/, '');
const channel = page.locator('.channel-list-item').filter({
hasText: '1. ClearKey DASH',
});
await channel.locator('.favorite-button').click();
await expect(channel.locator('.favorite-button mat-icon')).toHaveText(
'star'
);
for (const route of [
`${playlistUrl}/recent`,
`${playlistUrl}/favorites`,
'/workspace/global-recent',
'/workspace/global-favorites',
]) {
// Full navigation also proves persisted channels survive a cold load.
await page.goto(route);
if (route.startsWith('/workspace/global-')) {
const allPlaylists = page
.locator('.scope-toggle')
.getByRole('radio', { name: 'All playlists' });
await allPlaylists.click();
// The global routes default to the active playlist's scope; make
// sure the toggle took before waiting for a row only "All
// playlists" can show, or a lost click surfaces as a missing row.
await expect(allPlaylists).toBeChecked();
}
const collection = page.locator('app-unified-live-tab');
await collection
.locator('.channel-name')
.filter({
hasText: 'ClearKey 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(
'This player does not support the stream’s DRM configuration.'
);
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('DRM declared by source');
await expect(details).toContainText('Widevine');
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);
});
test('@web @m3u @dash manifest DRM and codecs survive a startup failure', async ({
page,
}) => {
await serveDashFixtures(page);
let manifestRequests = 0;
const body = readFileSync(
join(FIXTURE_DIR, 'clear.mpd'),
'utf8'
).replaceAll(
'<Representation ',
'<ContentProtection schemeIdUri="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"/>' +
'<ContentProtection schemeIdUri="urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95"/>' +
'<!-- private-license-marker -->' +
'<Representation '
);
await page.route(`${FIXTURE_HOST}/clear.mpd`, async (route) => {
manifestRequests += 1;
await route.fulfill({
status: 200,
contentType: 'application/dash+xml',
body,
});
});
await importDashPlaylist(page);
await page.getByText('2. Clear DASH').click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 20_000 });
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('Widevine, PlayReady');
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
await expect(details).toContainText('opus');
await expect(details).not.toContainText('private-license-marker');
expect(manifestRequests).toBe(1);
});
test('@web @m3u @dash segment failures show their stage and preserve codecs', async ({
page,
context,
}) => {
await serveDashFixtures(page);
await page.route(`${FIXTURE_HOST}/clear-video.mp4`, (route) =>
route.fulfill({ status: 403, body: 'private-response-marker' })
);
await importDashPlaylist(page);
await page.getByText('2. Clear DASH').click();
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
await expect(banner).toBeVisible({ timeout: 20_000 });
await banner.locator('summary').click();
const details = banner.locator(
'[data-test-id="playback-diagnostic-details"]'
);
await expect(details).toContainText('Media segment loading');
await expect(details).toContainText('vp09.00.11.08.01.02.02.02.00');
await expect(details).toContainText('HTTP 403');
await expect(details).not.toContainText('private-response-marker');
await expect(details).not.toContainText('DRM declared by source');
await expect(banner).toContainText(
'The server denied access to the media segments.'
);
await expect(banner).not.toContainText(/token.*expired/i);
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const copy = banner.getByRole('button', {
name: 'Copy diagnostics',
exact: true,
});
await copy.click();
await expect(
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
).toHaveText('Diagnostics copied');
const reportText = await page.evaluate(() =>
navigator.clipboard.readText()
);
expect(JSON.parse(reportText)).toMatchObject({
app: 'IPTVnator',
engine: 'shaka',
stage: 'segment',
httpStatus: 403,
videoCodecs: ['vp09.00.11.08.01.02.02.02.00'],
});
for (const secret of [
'private-response-marker',
FIXTURE_HOST,
'sourceUrl',
]) {
expect(reportText).not.toContain(secret);
}
await page.getByText('3. Widevine DASH').click();
await expect(banner).toContainText(
'This player does not support the stream’s DRM configuration.'
);
await expect(
banner.locator('[data-test-id="playback-copy-diagnostic-status"]')
).toBeEmpty();
await copy.click();
const next = JSON.parse(
await page.evaluate(() => navigator.clipboard.readText())
);
expect(next).toMatchObject({
code: 'drm-or-encryption',
drmSystems: ['widevine'],
});
expect(next).not.toHaveProperty('httpStatus');
expect(next).not.toHaveProperty('stage');
});