Merge origin/master into claude/parental-control-feature-31dde2

Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:12:58 +02:00
commit ae5d3f49de
732 files changed
+42572 -15380

No files matched your search

+170 -1
View File
@@ -4,6 +4,12 @@ import { expect, test } from './fixtures';
const fixturePath = join(__dirname, 'fixtures/test.m3u');
test('@web @m3u basic playlist import flow', async ({ page }) => {
// The fixture's placeholder media hosts must not delay navigation or make
// persistence coverage depend on external DNS and asset availability.
await page.route(
/^https?:\/\/(?:channel\.icons\.url|example\.channels|xml-url)\//,
(route) => route.abort()
);
await page.goto('/');
// Basic checks
@@ -29,6 +35,15 @@ test('@web @m3u basic playlist import flow', async ({ page }) => {
await expect(page.getByText('4 channels')).toBeVisible();
await expect(page.getByText('1. Channel 1')).toBeVisible();
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
const importedPlaylistUrl = page.url();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(importedPlaylistUrl);
await expect(page.getByText('test', { exact: true })).toBeVisible();
await expect(page.getByText('4 channels')).toBeVisible();
await expect(page.getByText('1. Channel 1')).toBeVisible();
await expect(page.getByText('4. HappyKids TV')).toBeVisible();
});
test('@web @auto-detect pasted provider message prefills the Xtream form', async ({
@@ -110,7 +125,161 @@ test('@web @auto-detect keeps the pasted message when switching methods', async
);
});
test('@web keyboard shortcuts help opens from question mark', async ({ page }) => {
test('@web @m3u reads an existing v1 IndexedDB playlist after application upgrade', async ({
page,
}) => {
const playlistId = 'legacy-indexeddb-playlist';
// Seed before Angular boots so the application must open the old database.
await page.route('**/__indexeddb-seed__', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html>' })
);
await page.route('https://legacy.example.invalid/**', (route) =>
route.abort()
);
await page.goto('/__indexeddb-seed__');
await page.evaluate(
(id) =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.open('iptvnator', 1);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => {
const store = request.result.createObjectStore(
'playlists',
{
keyPath: '_id',
autoIncrement: false,
}
);
// Freeze the historical schema independently of app config.
for (const name of [
'_id',
'filename',
'title',
'count',
'playlist',
'importDate',
'lastUsage',
'favorites',
'recentlyViewed',
'autoRefresh',
'url',
'filePath',
]) {
store.createIndex(name, name, { unique: false });
}
};
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(
'playlists',
'readwrite'
);
transaction.onabort = () => {
db.close();
reject(transaction.error);
};
transaction.oncomplete = () => {
db.close();
resolve();
};
transaction.objectStore('playlists').add({
_id: id,
title: 'Legacy playlist',
filename: 'legacy.m3u',
importDate: '2025-01-01T00:00:00.000Z',
lastUsage: '2025-01-01T00:00:00.000Z',
count: 1,
autoRefresh: false,
favorites: [],
recentlyViewed: [],
playlist: {
header: { attrs: {}, raw: '#EXTM3U' },
items: [
{
id: 'legacy-channel',
name: 'Legacy channel',
url: 'https://legacy.example.invalid/live.m3u8',
group: { title: 'News' },
tvg: {
id: '',
name: '',
url: '',
logo: '',
rec: '',
},
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: 'false',
},
],
},
});
};
}),
playlistId
);
const playlistPath = `/workspace/playlists/${playlistId}/all`;
await page.goto(playlistPath, { waitUntil: 'domcontentloaded' });
await expect(
page.getByText('Legacy playlist', { exact: true })
).toBeVisible();
await expect(
page.getByText('1. Legacy channel', { exact: true })
).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(new RegExp(`${playlistPath}$`));
await expect(
page.getByText('Legacy playlist', { exact: true })
).toBeVisible();
await expect(
page.getByText('1. Legacy channel', { exact: true })
).toBeVisible();
const persisted = await page.evaluate(
(id) =>
new Promise((resolve, reject) => {
const request = indexedDB.open('iptvnator');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('playlists', 'readonly');
const store = transaction.objectStore('playlists');
const record = store.get(id);
transaction.onabort = () => {
db.close();
reject(transaction.error);
};
transaction.oncomplete = () => {
resolve({
version: db.version,
keyPath: store.keyPath,
autoIncrement: store.autoIncrement,
id: record.result?._id,
channel: record.result?.playlist.items[0].name,
});
db.close();
};
};
}),
playlistId
);
expect(persisted).toEqual({
version: 1,
keyPath: '_id',
autoIncrement: false,
id: playlistId,
channel: 'Legacy channel',
});
});
test('@web keyboard shortcuts help opens from question mark', async ({
page,
}) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open keyboard shortcuts' }).focus();
+35 -3
View File
@@ -66,6 +66,18 @@ test.skip(
'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());
@@ -150,13 +162,29 @@ test('@web @m3u @dash ClearKey and clear DASH channels play inline', async ({
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();
@@ -179,10 +207,14 @@ test('@web @m3u @dash ClearKey reopens from recent and favorites collections', a
// Full navigation also proves persisted channels survive a cold load.
await page.goto(route);
if (route.startsWith('/workspace/global-')) {
await page
const allPlaylists = page
.locator('.scope-toggle')
.getByText('All playlists', { exact: true })
.click();
.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
+26
View File
@@ -7,6 +7,32 @@ import type {
import { expect } from './fixtures';
import sharp from 'sharp';
/**
* Move keyboard focus to the next (or previous) tabbable element the way a
* user of that browser does.
*
* Safari's default keyboard preference makes plain Tab visit only text fields
* and links; buttons are reached with Option+Tab, or with plain Tab once
* "Press Tab to highlight each item on a webpage" is enabled. Playwright's
* WebKit emulates that default, so a plain `Tab` from a focused channel pane
* lands on the sidebar search field instead of the first row button — the
* app's focus order is intact, Safari simply skips the button. Pressing
* Option+Tab there keeps the assertion about the app's DOM order rather than
* about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain
* Tab, but they get the unmodified key so their run stays a literal user Tab.
*/
export async function pressTab(
page: Page,
browserName: string,
direction: 'forward' | 'backward' = 'forward'
): Promise<void> {
const modifiers = [
...(browserName === 'webkit' ? ['Alt'] : []),
...(direction === 'backward' ? ['Shift'] : []),
];
await page.keyboard.press([...modifiers, 'Tab'].join('+'));
}
export async function setInputValue(
input: Locator,
value: string
+8 -5
View File
@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from './fixtures';
import { waitForScrollIdle } from './e2e-helpers';
import { pressTab, waitForScrollIdle } from './e2e-helpers';
/**
* The M3U movie-recognition workflow end to end: a playlist entry whose URL
@@ -406,11 +406,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
)
)
.toBe(0.25);
// M3U has no browse Back target, but the sticky watch control can close it.
// M3U has no browse Back target, so the shell shows no arrow in either
// state; the now-playing bar's own Close button returns to browse.
const shell = detail(page).locator('app-portal-detail-shell');
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
await shell
.locator('app-portal-inline-player')
.getByRole('button', { name: 'Close player', exact: true })
.first()
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
@@ -422,6 +424,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
for (const theme of ['light', 'dark']) {
test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({
page,
browserName,
}) => {
await serveSeekableClip(page);
await selectPlayer(page);
@@ -450,7 +453,7 @@ for (const theme of ['light', 'dark']) {
await expect
.poll(() => viewport.evaluate((el) => el.scrollTop))
.toBe(0);
await page.keyboard.press('Tab');
await pressTab(page, browserName);
await expect(
viewport.locator('button.channel-content').first()
).toBeFocused();
@@ -459,7 +462,7 @@ for (const theme of ['light', 'dark']) {
await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass(
/active/
);
await page.keyboard.press('Tab');
await pressTab(page, browserName);
const favorite = viewport.locator('.favorite-button').nth(1);
await expect(favorite).toBeFocused();
await page.keyboard.press('Space');
@@ -0,0 +1,32 @@
import { expect, Page } from '@playwright/test';
/**
* Enters the ITV section, selects the second category (the first row is
* "All") and plays its first channel, returning every `create_link` request
* observed so callers can assert whether a link was minted.
*/
export async function playFirstItvChannel(page: Page): Promise<string[]> {
const createLinkRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=create_link')) {
createLinkRequests.push(request.url());
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
await channels.first().click();
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-web-player-view')).toBeVisible({
timeout: 20_000,
});
return createLinkRequests;
}
@@ -0,0 +1,70 @@
import { expect, Page } from '@playwright/test';
/**
* Favorites → "Open in <portal>" → the same channel selected and playing in
* the portal's ITV section (the live counterpart of "View in portal").
*
* Runs against the PWA, where the collection tab renders no EPG panel and
* therefore no toolbar chip, so the row context menu is the entry point. The
* mock portal answers `get_all_channels`, so the layout locates the channel
* through the full ITV list and lands in its genre.
*
* Expects the portal to be imported already (`addStalkerPortal`).
*/
export async function verifyStalkerOpenInPlaylist(
page: Page,
portalName: string
): Promise<void> {
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const portalUrl = new URL(page.url());
const itvPath = portalUrl.pathname;
// Favorite the first channel of the second category (the first row is
// "All"), remembering its name for the round-trip assertion.
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
const channelName = (
await channels.first().locator('.channel-name').innerText()
).trim();
expect(channelName).not.toBe('');
await channels.first().locator('.favorite-button').click();
await expect(
channels.first().locator('.favorite-button mat-icon')
).toHaveText('star', { timeout: 10_000 });
// Global favorites, all playlists: the row offers the jump.
await page.goto('/workspace/global-favorites');
const allPlaylists = page
.locator('.scope-toggle')
.getByRole('radio', { name: 'All playlists' });
if (await allPlaylists.count()) {
await allPlaylists.click();
}
const favoriteRow = page
.locator('app-global-favorites-list [data-test-id="channel-item"]')
.filter({ hasText: channelName })
.first();
await expect(favoriteRow).toBeVisible({ timeout: 20_000 });
await favoriteRow.click({ button: 'right' });
const openInPlaylist = page.locator(
'[data-testid="channel-open-in-playlist"]'
);
await expect(openInPlaylist).toBeVisible({ timeout: 10_000 });
await expect(openInPlaylist).toContainText(portalName);
await openInPlaylist.click();
// Lands inside the portal's ITV section with that channel selected and
// playing, in its own genre.
await page.waitForURL((url) => url.pathname === itvPath, {
timeout: 20_000,
});
const activeRow = page.locator('[data-test-id="channel-item"].active');
await expect(activeRow).toContainText(channelName, { timeout: 30_000 });
await expect(page.locator('app-web-player-view')).toBeVisible({
timeout: 20_000,
});
}
+9 -26
View File
@@ -6,6 +6,8 @@ import {
verifyUncachedStalkerSearch,
} from './stalker-category-search.fixture';
import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture';
import { verifyStalkerOpenInPlaylist } from './stalker-open-in-playlist.fixture';
import { playFirstItvChannel } from './stalker-itv-playback.fixture';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
@@ -467,6 +469,13 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => {
expect(shortEpgRequests).toHaveLength(0);
});
test('@stalker ITV favorite opens inside its portal from global favorites', async ({
page,
}) => {
await addStalkerPortal(page);
await verifyStalkerOpenInPlaylist(page, 'Mock Stalker Portal');
});
test('@stalker ITV playback survives a category switch', async ({ page }) => {
await addStalkerPortal(page);
await verifyStalkerPlaybackCategoryReturn(page);
@@ -891,32 +900,6 @@ test('@stalker create_link returns a playable stream URL', async ({
* Play the first channel of the first ITV category and report every
* `create_link` request the page made while doing so.
*/
async function playFirstItvChannel(page: Page): Promise<string[]> {
const createLinkRequests: string[] = [];
page.on('request', (request) => {
if (request.url().includes('action=create_link')) {
createLinkRequests.push(request.url());
}
});
await page.getByRole('link', { name: /live|itv/i }).click();
await page.waitForURL(/stalker.*itv/);
const categories = page.locator('.category-item');
await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 });
await categories.nth(1).click();
const channels = page.locator('[data-test-id="channel-item"]');
await expect(channels.first()).toBeVisible({ timeout: 20_000 });
await channels.first().click();
await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 });
await expect(page.locator('app-web-player-view')).toBeVisible({
timeout: 20_000,
});
return createLinkRequests;
}
test('@stalker ITV plays an unflagged channel without minting a link', async ({
page,
}) => {
@@ -0,0 +1,242 @@
import { expect, test } from './fixtures';
import {
interceptXtreamRequests,
MOCK_SERVER,
playFirstSeriesEpisode,
rewriteSeriesEpisodesToMp4,
routeEpisodeClip,
selectWebPlayer,
} from './xtream-series-playback.fixture';
/**
* Xtream inline series playback in fullscreen: episode switches, the
* fullscreen episode panel and the vendor-chrome shortcuts. Split out of
* `xtream.e2e.ts` for size; same mock server and default scenario
* (3 seasons × 8 episodes per series).
*
* Tag: @xtream
*/
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await interceptXtreamRequests(page);
});
// ---------------------------------------------------------------------------
// Fullscreen survives an episode switch
//
// app-web-player-view remounts the engine component for every playback
// application (next episode, channel, or alternative source). DOM fullscreen
// used to be owned by that engine's shell, so the Fullscreen API exited the
// moment the old shell left the document — every "next episode" click and
// every autoplay hand-off dropped the viewer back to the page. The owner is
// now the app-web-player-view host, which spans all applications of one mount.
// ---------------------------------------------------------------------------
test.describe('@xtream inline series fullscreen', () => {
// No autoplay-policy flag is needed: the episode click is a user
// activation and the fixture clip carries no audio track.
test.skip(
({ browserName }) => browserName !== 'chromium',
'DOM fullscreen assertions target Chromium'
);
test('stays in fullscreen across manual and automatic episode switches', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
// Persist the HTML5 engine first: Video.js rejects the mock's
// video/matroska source type before any bytes are sniffed.
await selectWebPlayer(page, 'HTML5 video player');
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
const waitForMetadata = () =>
expect
.poll(() =>
video.evaluate((el) => (el as HTMLVideoElement).readyState)
)
.toBeGreaterThanOrEqual(1);
await waitForMetadata();
// The bar auto-hides during playback; a hover reveals it. The
// overlay title renders only while the controls consider themselves
// fullscreen, so it doubles as the controls-state assertion.
// Asserted as "some element is fullscreen" rather than by owner tag,
// so the test guards the behavior, not the wiring.
const fullscreenOwner = () =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
const overlayTitle = playerView.locator(
'[data-test-id="player-controls-media-title"]'
);
await playerView.hover();
await playerView
.getByRole('button', { name: 'Enter fullscreen' })
.click();
await expect.poll(fullscreenOwner).not.toBeNull();
await expect(overlayTitle).toContainText('S01E01');
// Chromium leaves the clicked button focused; the shared controls
// release that focus so Space reaches the playback shortcut instead
// of activating the button again (which left fullscreen while the
// video kept playing).
const paused = () =>
video.evaluate((el) => (el as HTMLVideoElement).paused);
await expect.poll(paused).toBe(false);
await page.keyboard.press('Space');
await expect.poll(paused).toBe(true);
expect(await fullscreenOwner()).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(false);
// Manual switch from the shared controls' own next-episode button.
await playerView.hover();
await playerView
.locator('[data-test-id="player-controls-next-episode"]')
.click();
await expect(overlayTitle).toContainText('S01E02', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
// Automatic switch: the clip reaching its end autoplays the next
// episode with no user activation anywhere near it.
await waitForMetadata();
await video.evaluate(async (el) => {
const media = el as HTMLVideoElement;
media.currentTime = Math.max(0, media.duration - 0.2);
// Headless autoplay is not guaranteed for a remounted element;
// the document already carries user activation from the clicks.
await media.play();
});
await expect(overlayTitle).toContainText('S01E03', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
});
test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
await selectWebPlayer(page, 'HTML5 video player');
const { playerView } = await playFirstSeriesEpisode(page, request);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
const fullscreenOwner = () =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
const overlayTitle = playerView.locator(
'[data-test-id="player-controls-media-title"]'
);
await playerView.hover();
await playerView
.getByRole('button', { name: 'Enter fullscreen' })
.click();
await expect.poll(fullscreenOwner).not.toBeNull();
await expect(overlayTitle).toContainText('S01E01');
// C slides in the episode panel: season tabs instead of a search
// field, the season's eight rows, the playing one marked.
await page.keyboard.press('c');
const panel = page.locator('[data-test-id="fullscreen-channel-panel"]');
await expect(panel).toHaveAttribute('aria-hidden', 'false');
await expect(panel).toHaveAttribute('data-panel-kind', 'episodes');
await expect(
panel.locator('[data-test-id="fullscreen-channel-panel-search"]')
).toHaveCount(0);
await expect(panel.locator('.season-tabs__pill')).toHaveCount(3);
const rows = panel.locator(
'[data-test-id="fullscreen-episode-panel-episode"]'
);
await expect(rows).toHaveCount(8);
await expect(rows.first()).toHaveAttribute('aria-current', 'true');
// Picking an episode plays it inline through the same path as the Up
// Next rail: fullscreen survives the engine remount, the panel closes.
await rows.nth(2).click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
await playerView.hover();
await expect(overlayTitle).toContainText('S01E03', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
// Reopened, the panel marks the new episode; another season's tab
// lists that season, offers the way back, and plays from there too.
await page.keyboard.press('c');
await expect(panel).toHaveAttribute('aria-hidden', 'false');
await expect(rows.nth(2)).toHaveAttribute('aria-current', 'true');
await panel.locator('.season-tabs__pill').nth(1).click();
await expect(rows).toHaveCount(8);
await expect(panel.locator('[aria-current="true"]')).toHaveCount(0);
await expect(
panel.locator('[data-testid="back-to-playing"]')
).toBeVisible();
await rows.first().click();
await playerView.hover();
await expect(overlayTitle).toContainText('S02E01', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
});
});
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
test.skip(
({ browserName }) => browserName !== 'chromium',
'DOM fullscreen assertions target Chromium'
);
test('keep working after a mouse click on a control-bar button', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
// Video.js with the shared controls opted out: the vendor control bar.
await selectWebPlayer(page, 'Video.js player', false);
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-vjs-player')).toBeVisible({
timeout: 15_000,
});
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
const paused = () =>
video.evaluate((el) => (el as HTMLVideoElement).paused);
const fullscreenOwner = () =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
await expect.poll(paused).toBe(false);
// Chromium leaves the clicked Video.js button focused, and a focused
// Video.js component swallows every key; the legacy chrome releases
// that focus so Space and M reach the playback shortcuts instead of
// Space activating the button again (which left fullscreen while the
// video kept playing).
await playerView.hover();
await playerView.locator('.vjs-fullscreen-control').click();
await expect.poll(fullscreenOwner).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(true);
expect(await fullscreenOwner()).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(false);
await page.keyboard.press('m');
await expect
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
.toBe(true);
});
});
@@ -0,0 +1,234 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import { expect } from './fixtures';
import { setInputValue } from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
/**
* Shared Xtream helpers for `xtream.e2e.ts` and `xtream-series-playback.e2e.ts`:
* the mock-server address and credentials, the PWA proxy intercept, the
* portal import, and the inline series playback setup.
*/
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
export const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
/** Default scenario credentials */
export const DEFAULT_USERNAME = 'user1';
export const DEFAULT_PASSWORD = 'pass1';
/**
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
* to the mock server. This avoids any real backend requirement.
*/
export async function interceptXtreamRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/xtream**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add an Xtream portal via the UI.
*/
export async function addXtreamPortal(
page: Page,
options: { name?: string; username?: string; password?: string } = {}
): Promise<void> {
const {
name = 'Mock Xtream Portal',
username = DEFAULT_USERNAME,
password = DEFAULT_PASSWORD,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await setInputValue(dialog.locator('#title'), name);
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
await setInputValue(dialog.locator('#username'), username);
await setInputValue(dialog.locator('#password'), password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
await page.waitForURL(/xtreams.*vod/);
}
// ---------------------------------------------------------------------------
// Inline series playback helpers
// ---------------------------------------------------------------------------
/**
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
* proprietary codecs, so the clip stands in for the mock's external HLS
* redirect; the browser sniffs the WebM container from the bytes. Registered
* after the beforeEach proxy route, so it runs first and fetches from the
* mock itself (the mock ignores the url parameter).
*/
export async function routeEpisodeClip(page: Page): Promise<void> {
const episodeClip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(
(url) =>
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
async (route) => {
// Chromium's media pipeline seeks through byte ranges; a plain
// 200 to a Range request clamps every seek to the start, which
// would defeat end-of-episode steps.
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = episodeClip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, episodeClip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2]
? Math.min(Number(range[2]), last)
: last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
'content-type': 'video/webm',
...(range
? {
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
}
: {}),
},
body: episodeClip.subarray(start, end + 1),
});
}
);
}
/**
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
* get_series_info is rewritten to .mp4 — the extension the player gives to
* the native source path.
*/
export async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
await page.route('**/localhost:3000/xtream**', async (route) => {
const original = new URL(route.request().url());
if (original.searchParams.get('action') !== 'get_series_info') {
await route.fallback();
return;
}
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
original.searchParams.forEach((value, key) => {
if (key !== 'targetId') {
mockUrl.searchParams.set(key, value);
}
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = (await response.json()) as {
payload: {
episodes?: Record<
string,
Array<{ container_extension: string }>
>;
};
};
for (const episodes of Object.values(body.payload.episodes ?? {})) {
for (const episode of episodes) {
episode.container_extension = 'mp4';
}
}
await route.fulfill({ response, json: body });
});
}
/** Persist the web player engine and the shared-controls preference. */
export async function selectWebPlayer(
page: Page,
engine: string,
sharedControls = true
): Promise<void> {
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page.getByRole('option', { name: engine, exact: true }).click();
const toggle = page.locator(
'[data-test-id="web-player-shared-controls-toggle"]'
);
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
await toggle.click();
}
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
/** Add the mock portal and start the first episode of the first series. */
export async function playFirstSeriesEpisode(
page: Page,
request: APIRequestContext
): Promise<{ playerView: Locator; video: Locator }> {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await page.goto('/');
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
const episodeCards = page.locator('.episode-card');
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
await episodeCards.first().click();
const playerView = page.locator(
'app-portal-inline-player app-web-player-view'
);
return { playerView, video: playerView.locator('video') };
}
+124 -376
View File
@@ -1,17 +1,19 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import type { APIRequestContext, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
pressTab,
rasterizedBorderContrast,
setInputValue,
surfaceContrast,
waitForScrollIdle,
} from './e2e-helpers';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
addXtreamPortal,
DEFAULT_PASSWORD,
DEFAULT_USERNAME,
interceptXtreamRequests,
MOCK_SERVER,
} from './xtream-series-playback.fixture';
/**
* Xtream Codes E2E Tests
@@ -28,12 +30,6 @@ import {
* Tag: @xtream — run only Xtream tests with: nx e2e web-e2e --grep "@xtream"
*/
const XTREAM_MOCK_PORT = process.env['XTREAM_MOCK_PORT'] ?? '3211';
const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
/** Default scenario credentials */
const DEFAULT_USERNAME = 'user1';
const DEFAULT_PASSWORD = 'pass1';
const EPG_USERNAME = 'epg';
const EPG_PASSWORD = 'epg';
@@ -41,65 +37,6 @@ const EPG_PASSWORD = 'epg';
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept calls from the Angular PWA proxy (/xtream) and redirect them
* to the mock server. This avoids any real backend requirement.
*/
async function interceptXtreamRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/xtream**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
/**
* Add an Xtream portal via the UI.
*/
async function addXtreamPortal(
page: Page,
options: { name?: string; username?: string; password?: string } = {}
): Promise<void> {
const {
name = 'Mock Xtream Portal',
username = DEFAULT_USERNAME,
password = DEFAULT_PASSWORD,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
// v0.22 redesign: tabs were replaced with a flat 5-card radio picker.
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
await setInputValue(dialog.locator('#title'), name);
await setInputValue(dialog.locator('#serverUrl'), MOCK_SERVER);
await setInputValue(dialog.locator('#username'), username);
await setInputValue(dialog.locator('#password'), password);
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
await page.waitForURL(/xtreams.*vod/);
}
async function openPlaylistDetailsDialog(page: Page, title: string) {
const playlistSettingsButton = page.getByRole('button', {
name: 'Playlist Settings',
@@ -782,6 +719,76 @@ test('@xtream season watched toggle — marks a season, survives reload, and cle
await expect(page.locator('.season-tabs__done')).toHaveCount(0);
});
// ---------------------------------------------------------------------------
// Season cover beside the season tabs (provider tier)
//
// The default scenario's get_series_info carries a distinct seasons[].cover
// per season, which differs from the show cover, so with TMDB enrichment off
// the serial details page shows that cover next to the tabs and swaps it on
// a tab click. Image requests are answered locally so a slow or blocked CDN
// cannot fold the column (the app hides a cover whose image failed).
// ---------------------------------------------------------------------------
test('@xtream season cover — shows the provider season cover and follows the selected tab', async ({
page,
request,
}) => {
const TRANSPARENT_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64'
);
await page.route('**/picsum.photos/**', (route) =>
route.fulfill({ contentType: 'image/png', body: TRANSPARENT_PNG })
);
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
// Season 1 is auto-selected; its provider cover sits beside the tabs.
// The mock seeds season art as `season-<id>-<n>` (cover) and
// `season-big-<id>-<n>` (cover_big); the app prefers cover_big.
const cover = page.locator('[data-testid="season-cover"]');
await expect(cover).toBeVisible({ timeout: 15_000 });
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-1/`)
);
// The cover follows the selected tab.
await page.locator('.season-tabs__pill').nth(1).click();
await expect(cover).toHaveAttribute(
'src',
new RegExp(`season(-big)?-${targetSeries.series_id}-2/`)
);
});
// ---------------------------------------------------------------------------
// Series-level watched toggle: the season header's ⋮ menu marks EVERY season
// in one action (default scenario: 3 seasons × 8 episodes = 24), flips to
@@ -883,6 +890,48 @@ type XtreamRawEpgListing = {
title: string;
};
test('@xtream posters-only wall — hides VOD titles behind a hover caption after Save', async ({
page,
}) => {
await addXtreamPortal(page);
await page.locator('.context-panel .category-item').first().click();
const grid = page.locator('app-grid-list');
const firstCard = grid.locator('mat-card').first();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
await expect(firstCard.locator('.title')).toBeVisible();
await expect(grid).not.toHaveClass(/grid-list--posters-only/);
const catalogUrl = page.url();
await page.goto('/workspace/settings/general');
const toggle = page.locator('[data-test-id="cover-titles-toggle"]');
await expect(toggle.locator('input')).toBeChecked();
await toggle.click();
const saveButton = page.locator('[data-test-id="save-settings"]');
await saveButton.click();
await expect(saveButton).toBeHidden();
await page.goto(catalogUrl);
await page.locator('.context-panel .category-item').first().click();
await expect(firstCard).toBeVisible({ timeout: 10_000 });
await expect(grid).toHaveClass(/grid-list--posters-only/);
await expect(firstCard.locator('.title')).toHaveCount(0);
// The caption is a hover/focus reveal: hidden at rest, shown when the
// pointer or keyboard focus lands on the card.
const caption = firstCard.locator('.cover-title-overlay');
await expect(caption).toHaveCSS('opacity', '0');
await firstCard.hover();
await expect(caption).toHaveCSS('opacity', '1');
await page.mouse.move(0, 0);
await expect(caption).toHaveCSS('opacity', '0');
await firstCard.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(firstCard).toBeFocused();
await expect(caption).toHaveCSS('opacity', '1');
});
async function getEpgFixtureStream(
request: APIRequestContext
): Promise<XtreamLiveStream> {
@@ -923,308 +972,6 @@ function formatXtreamDateTime(timestampSeconds: number): string {
.replace('.000Z', '');
}
// ---------------------------------------------------------------------------
// Fullscreen survives an episode switch
//
// app-web-player-view remounts the engine component for every playback
// application (next episode, channel, or alternative source). DOM fullscreen
// used to be owned by that engine's shell, so the Fullscreen API exited the
// moment the old shell left the document — every "next episode" click and
// every autoplay hand-off dropped the viewer back to the page. The owner is
// now the app-web-player-view host, which spans all applications of one mount.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Inline series playback helpers
// ---------------------------------------------------------------------------
/**
* Serve a tiny VP8 clip for every episode stream. Chromium ships no
* proprietary codecs, so the clip stands in for the mock's external HLS
* redirect; the browser sniffs the WebM container from the bytes. Registered
* after the beforeEach proxy route, so it runs first and fetches from the
* mock itself (the mock ignores the url parameter).
*/
async function routeEpisodeClip(page: Page): Promise<void> {
const episodeClip = readFileSync(
join(__dirname, 'fixtures/playback/episode.webm')
);
await page.route(
(url) =>
url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'),
async (route) => {
// Chromium's media pipeline seeks through byte ranges; a plain
// 200 to a Range request clamps every seek to the start, which
// would defeat end-of-episode steps.
const range = /^bytes=(\d*)-(\d*)$/.exec(
route.request().headers()['range'] ?? ''
);
const last = episodeClip.length - 1;
const start = range?.[1]
? Number(range[1])
: range?.[2]
? Math.max(0, episodeClip.length - Number(range[2]))
: 0;
const end =
range?.[1] && range[2]
? Math.min(Number(range[2]), last)
: last;
await route.fulfill({
status: range ? 206 : 200,
headers: {
'accept-ranges': 'bytes',
'content-length': String(end - start + 1),
'content-type': 'video/webm',
...(range
? {
'content-range': `bytes ${start}-${end}/${episodeClip.length}`,
}
: {}),
},
body: episodeClip.subarray(start, end + 1),
});
}
);
}
/**
* The mock's episodes are .mkv, which the HTML5 player would hand to hls.js;
* get_series_info is rewritten to .mp4 — the extension the player gives to
* the native source path.
*/
async function rewriteSeriesEpisodesToMp4(page: Page): Promise<void> {
await page.route('**/localhost:3000/xtream**', async (route) => {
const original = new URL(route.request().url());
if (original.searchParams.get('action') !== 'get_series_info') {
await route.fallback();
return;
}
const mockUrl = new URL(`${MOCK_SERVER}/xtream`);
original.searchParams.forEach((value, key) => {
if (key !== 'targetId') {
mockUrl.searchParams.set(key, value);
}
});
const response = await route.fetch({ url: mockUrl.toString() });
const body = (await response.json()) as {
payload: {
episodes?: Record<
string,
Array<{ container_extension: string }>
>;
};
};
for (const episodes of Object.values(body.payload.episodes ?? {})) {
for (const episode of episodes) {
episode.container_extension = 'mp4';
}
}
await route.fulfill({ response, json: body });
});
}
/** Persist the web player engine and the shared-controls preference. */
async function selectWebPlayer(
page: Page,
engine: string,
sharedControls = true
): Promise<void> {
await page.goto('/workspace/settings/playback');
await page.locator('[data-test-id="select-video-player"]').click();
await page.getByRole('option', { name: engine, exact: true }).click();
const toggle = page.locator(
'[data-test-id="web-player-shared-controls-toggle"]'
);
if ((await toggle.locator('input').isChecked()) !== sharedControls) {
await toggle.click();
}
const saveButton = page.getByRole('button', { name: 'Save changes' });
await saveButton.click();
await expect(saveButton).toBeHidden();
}
/** Add the mock portal and start the first episode of the first series. */
async function playFirstSeriesEpisode(
page: Page,
request: APIRequestContext
): Promise<{ playerView: Locator; video: Locator }> {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const category = categories[0];
const seriesItems = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${DEFAULT_USERNAME}&password=${DEFAULT_PASSWORD}&action=get_series&category_id=${category.category_id}`
)
).json()) as Array<{ name: string; series_id: number }>;
const targetSeries = seriesItems[0];
await page.goto('/');
await addXtreamPortal(page);
await page.goto(page.url().replace(/\/vod.*$/, '/series'));
const categoryItem = page
.locator('.context-panel .category-item')
.filter({ hasText: category.category_name })
.first();
await expect(categoryItem).toBeVisible({ timeout: 10_000 });
await categoryItem.click();
const seriesCard = page
.locator('app-grid-list mat-card')
.filter({ hasText: targetSeries.name })
.first();
await expect(seriesCard).toBeVisible({ timeout: 10_000 });
await seriesCard.click();
const episodeCards = page.locator('.episode-card');
await expect(episodeCards).toHaveCount(8, { timeout: 15_000 });
await episodeCards.first().click();
const playerView = page.locator(
'app-portal-inline-player app-web-player-view'
);
return { playerView, video: playerView.locator('video') };
}
test.describe('@xtream inline series fullscreen', () => {
// No autoplay-policy flag is needed: the episode click is a user
// activation and the fixture clip carries no audio track.
test.skip(
({ browserName }) => browserName !== 'chromium',
'DOM fullscreen assertions target Chromium'
);
test('stays in fullscreen across manual and automatic episode switches', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
// Persist the HTML5 engine first: Video.js rejects the mock's
// video/matroska source type before any bytes are sniffed.
await selectWebPlayer(page, 'HTML5 video player');
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-html-video-player')).toBeVisible({
timeout: 15_000,
});
const waitForMetadata = () =>
expect
.poll(() =>
video.evaluate((el) => (el as HTMLVideoElement).readyState)
)
.toBeGreaterThanOrEqual(1);
await waitForMetadata();
// The bar auto-hides during playback; a hover reveals it. The
// overlay title renders only while the controls consider themselves
// fullscreen, so it doubles as the controls-state assertion.
// Asserted as "some element is fullscreen" rather than by owner tag,
// so the test guards the behavior, not the wiring.
const fullscreenOwner = () =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
const overlayTitle = playerView.locator(
'[data-test-id="player-controls-media-title"]'
);
await playerView.hover();
await playerView
.getByRole('button', { name: 'Enter fullscreen' })
.click();
await expect.poll(fullscreenOwner).not.toBeNull();
await expect(overlayTitle).toContainText('S01E01');
// Chromium leaves the clicked button focused; the shared controls
// release that focus so Space reaches the playback shortcut instead
// of activating the button again (which left fullscreen while the
// video kept playing).
const paused = () =>
video.evaluate((el) => (el as HTMLVideoElement).paused);
await expect.poll(paused).toBe(false);
await page.keyboard.press('Space');
await expect.poll(paused).toBe(true);
expect(await fullscreenOwner()).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(false);
// Manual switch from the shared controls' own next-episode button.
await playerView.hover();
await playerView
.locator('[data-test-id="player-controls-next-episode"]')
.click();
await expect(overlayTitle).toContainText('S01E02', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
// Automatic switch: the clip reaching its end autoplays the next
// episode with no user activation anywhere near it.
await waitForMetadata();
await video.evaluate(async (el) => {
const media = el as HTMLVideoElement;
media.currentTime = Math.max(0, media.duration - 0.2);
// Headless autoplay is not guaranteed for a remounted element;
// the document already carries user activation from the clicks.
await media.play();
});
await expect(overlayTitle).toContainText('S01E03', {
timeout: 15_000,
});
expect(await fullscreenOwner()).not.toBeNull();
});
});
test.describe('@xtream vendor-chrome Video.js shortcuts', () => {
test.skip(
({ browserName }) => browserName !== 'chromium',
'DOM fullscreen assertions target Chromium'
);
test('keep working after a mouse click on a control-bar button', async ({
page,
request,
}) => {
await routeEpisodeClip(page);
await rewriteSeriesEpisodesToMp4(page);
// Video.js with the shared controls opted out: the vendor control bar.
await selectWebPlayer(page, 'Video.js player', false);
const { playerView, video } = await playFirstSeriesEpisode(
page,
request
);
await expect(playerView.locator('app-vjs-player')).toBeVisible({
timeout: 15_000,
});
await expect(playerView.locator('app-player-controls')).toHaveCount(0);
const paused = () =>
video.evaluate((el) => (el as HTMLVideoElement).paused);
const fullscreenOwner = () =>
page.evaluate(() => document.fullscreenElement?.tagName ?? null);
await expect.poll(paused).toBe(false);
// Chromium leaves the clicked Video.js button focused, and a focused
// Video.js component swallows every key; the legacy chrome releases
// that focus so Space and M reach the playback shortcuts instead of
// Space activating the button again (which left fullscreen while the
// video kept playing).
await playerView.hover();
await playerView.locator('.vjs-fullscreen-control').click();
await expect.poll(fullscreenOwner).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(true);
expect(await fullscreenOwner()).not.toBeNull();
await page.keyboard.press('Space');
await expect.poll(paused).toBe(false);
await page.keyboard.press('m');
await expect
.poll(() => video.evaluate((el) => (el as HTMLVideoElement).muted))
.toBe(true);
});
});
for (const theme of ['light', 'dark']) {
test(`@xtream detail surfaces stay visible (${theme})`, async ({
page,
@@ -1302,6 +1049,7 @@ for (const theme of ['light', 'dark']) {
test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({
page,
browserName,
}) => {
await addXtreamPortal(page);
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
@@ -1317,10 +1065,10 @@ for (const theme of ['light', 'dark']) {
await page.keyboard.press('ArrowRight');
await expect(viewport).toBeFocused();
await expect(page.locator('app-web-player-view')).toHaveCount(0);
await page.keyboard.press('Tab');
await pressTab(page, browserName);
const firstAction = viewport.locator('button.channel-content').first();
await expect(firstAction).toBeFocused();
await page.keyboard.press('Shift+Tab');
await pressTab(page, browserName, 'backward');
await expect(viewport).toBeFocused();
const row = viewport.locator('.channel-name').first();
@@ -1345,7 +1093,7 @@ for (const theme of ['light', 'dark']) {
await page.keyboard.press('Space');
await expect(page.locator('app-web-player-view')).toBeVisible();
await expect(firstAction).toBeFocused();
await page.keyboard.press('Tab');
await pressTab(page, browserName);
await expect(
viewport.locator('.favorite-button').first()
).toBeFocused();