mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
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:
commit
ae5d3f49de
732 files changed
+42572
-15380
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user